Compare commits
16
Commits
538b4ede09
..
v2.4.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24f999facd | ||
|
|
3a648b7d77 | ||
|
|
fde9615b34 | ||
|
|
c93a20f20c | ||
|
|
1466d0fbab | ||
|
|
6c8de4aef3 | ||
|
|
7f9f0ca128 | ||
|
|
edd2774d86 | ||
|
|
62fc5aaa5a | ||
|
|
023e136c53 | ||
|
|
4877802bcd | ||
|
|
40eb979924 | ||
|
|
e3bacc3143 | ||
|
|
cc823ec4f6 | ||
|
|
ec10b06848 | ||
|
|
81cf94187f |
@@ -34,12 +34,39 @@ Stand: 2026-07-24. Diese Datei listet die **aktiv verifizierten Findings** aus d
|
|||||||
- **Conflict-Resolver: farbliches Hervorheben eingefügter Zeilen im Result-Pane** (grüner „flow" der übernommenen Zeilen). §4.
|
- **Conflict-Resolver: farbliches Hervorheben eingefügter Zeilen im Result-Pane** (grüner „flow" der übernommenen Zeilen). §4.
|
||||||
- Als ClaudeDo-Tasks in Liste „Claude do" erfasst: **Approve erzwingt Diff/Review vor Merge** (koppelt den blocked-Merge-Silent-Fail-Fix) und **interaktive Planning-Session über embedded ConPTY statt externem wt-Fenster** (koppelt den Planning-Permission-Prompt).
|
- Als ClaudeDo-Tasks in Liste „Claude do" erfasst: **Approve erzwingt Diff/Review vor Merge** (koppelt den blocked-Merge-Silent-Fail-Fix) und **interaktive Planning-Session über embedded ConPTY statt externem wt-Fenster** (koppelt den Planning-Permission-Prompt).
|
||||||
|
|
||||||
|
## Sichtprüfung offen (2026-07-27, Batch „Claude do"-Liste)
|
||||||
|
|
||||||
|
Alles gebaut + unit-getestet, aber **nicht visuell verifiziert** — Mika prüft:
|
||||||
|
|
||||||
|
- **Ctrl+K fokussiert die Suche, `#` ist wieder tippbar** (Binding war `OemQuestion` = `#` auf DE-Layout).
|
||||||
|
- **Titel-Edit in der Detail-Insel persistiert** (400 ms debounced, wie die Beschreibung); Row-Titel + Merge-Kontext ziehen mit.
|
||||||
|
- **Spinner beim ConPTY-Start:** Tile erscheint sofort mit „Sitzung wird gestartet…" und deckt Launch-Spec-Roundtrip + Spawn ab. **Nicht** abgedeckt: die Sekunden, die die claude-TUI danach zum ersten Frame braucht (dafür bräuchte es einen Hook auf `TerminalControl.DataReceived`). Bei Launch-Fehler bleibt das Tile jetzt mit Inline-Banner stehen statt zu verschwinden.
|
||||||
|
- **Spinner beim Refine** ersetzt den Refine-Button in der Row, solange der Run läuft.
|
||||||
|
- **Interactive-Chip** statt „Parked" auf Tasks mit offener ConPTY-Session; Klick öffnet Mission Control und fokussiert die Pane. Accent-Tint (bewusst dieselbe „live"-Familie wie Running, klar unterschieden vom slate-blauen Parked).
|
||||||
|
- **Diff-Viewer:** rechter Abstand der `+n −n`-Zahlen im File-Tree.
|
||||||
|
- **Manual-Tasks:** MANUAL-Badge, Kontextmenü-Toggle, Listen-Checkbox „Manuelle Liste"; Queue/Refine/Planning ausgeblendet, ConPTY bleibt.
|
||||||
|
- **Settings → Allgemein:** Tabelle „Vorgaben pro Modell" (Effort + Max. Durchläufe je haiku/sonnet/opus/fable). Ersetzt das einzelne globale „Max. Durchläufe"-Feld.
|
||||||
|
|
||||||
|
Offene Entscheidungen dazu:
|
||||||
|
|
||||||
|
- Interaktive ConPTY-Sessions bekommen `--effort`, aber **kein** `--model` — die Session läuft weiter unter dem Modell aus Mikas Claude-Config, der Effort kommt aus dem Preset des Modells, das ClaudeDo für die Task auflösen würde. Falls ClaudeDo auch interaktiv das Modell erzwingen soll, ist das ein Folge-Task.
|
||||||
|
- `AppSettings.DefaultMaxTurns` ist nur noch Fallback für ein Modell ohne Preset-Zeile und hat keinen Editor mehr. Spalte könnte später entfallen.
|
||||||
|
|
||||||
## Beobachtung (offen — Entscheidung Mika)
|
## Beobachtung (offen — Entscheidung Mika)
|
||||||
|
|
||||||
- **`--permission-mode auto` + Modell `haiku` → Writes werden denied:** Kontrolliert verifiziert (CLI 2.1.207): unter dem Default-Mode `auto` bekommt **sonnet** Writes auto-approved (`permission_denials:[]`), **haiku** wird `denied` (`permission_denials:[Write]`, keine Datei) — eine haiku-Task macht unter `auto` still nichts und landet ohne Änderung in `WaitingForReview`. Normalbetrieb (Default = sonnet) nicht betroffen. KEINE CLI-Regression, sondern modellabhängiges `auto`-Verhalten. Optionen falls es nervt: haiku aus der Auswahl nehmen, ODER Runner auf `acceptEdits`/`bypassPermissions` (modell-unabhängig). Mika: erstmal beobachten. Siehe Memory `auto_permission_haiku_footgun`.
|
- **`--permission-mode auto` + Modell `haiku` → Writes werden denied:** Kontrolliert verifiziert (CLI 2.1.207): unter dem Default-Mode `auto` bekommt **sonnet** Writes auto-approved (`permission_denials:[]`), **haiku** wird `denied` (`permission_denials:[Write]`, keine Datei) — eine haiku-Task macht unter `auto` still nichts und landet ohne Änderung in `WaitingForReview`. Normalbetrieb (Default = sonnet) nicht betroffen. KEINE CLI-Regression, sondern modellabhängiges `auto`-Verhalten. Optionen falls es nervt: haiku aus der Auswahl nehmen, ODER Runner auf `acceptEdits`/`bypassPermissions` (modell-unabhängig). Mika: erstmal beobachten. Siehe Memory `auto_permission_haiku_footgun`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Offene Verifikation (2026-07-27)
|
||||||
|
|
||||||
|
- **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
|
||||||
|
LIST column. Plus a real-Claude smoke run of the five phases (dedupe questions, enhancements
|
||||||
|
landing in task descriptions, queued execution, merges).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Bewusst verworfen (nicht erneut vorschlagen)
|
## Bewusst verworfen (nicht erneut vorschlagen)
|
||||||
|
|
||||||
- **CI-Build/Test-Pipeline** — push-to-main + release-on-push deckt das ab; Tests laufen am Ende jeder Session.
|
- **CI-Build/Test-Pipeline** — push-to-main + release-on-push deckt das ab; Tests laufen am Ende jeder Session.
|
||||||
|
|||||||
@@ -0,0 +1,938 @@
|
|||||||
|
# Per-List Task Handler Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Make "Let Claude handle it" list-scoped only, and turn its prompt into a five-phase run — read all tasks, dedupe, enhance, queue, review+merge.
|
||||||
|
|
||||||
|
**Architecture:** Four independent commits. Two touch only leaf code (the MCP status tool, the prompt templates). One strips the global UI entry point. The last is an atomic sweep that makes `listId` non-nullable end to end and collapses the launch spec to a single repo — atomic because a half-flipped signature chain leaves nullable warnings scattered across a commit boundary.
|
||||||
|
|
||||||
|
**Tech Stack:** .NET 8, xUnit, Avalonia 12, EF Core + SQLite, CommunityToolkit.Mvvm.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-07-27-list-handler-design.md`
|
||||||
|
|
||||||
|
**Build note:** `dotnet build ClaudeDo.slnx` needs .NET 9 — build individual csproj with `-c Release` (a running Worker locks `Debug` output).
|
||||||
|
|
||||||
|
**Staging note:** the checkout is shared with parallel sessions. Always `git add -- <exact paths>` and `git commit -- <exact paths>`. Never `git add -A`, never a bare `git commit`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: `update_task_status` accepts `Cancelled`
|
||||||
|
|
||||||
|
Dedupe needs to retire an **Idle** duplicate. Today nothing can: `UpdateTaskStatus` allows only
|
||||||
|
`Idle`/`Queued`, `cancel_task` only cancels a *running* task, and `review_task(decision="cancel")`
|
||||||
|
requires WaitingForReview/Running/Queued. `TaskStateService.CancelAsync` already owns the
|
||||||
|
transition and its side effects.
|
||||||
|
|
||||||
|
`BatchMcpTools.BatchUpdateTaskStatus` delegates to this same method, so batch cancel comes free.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Worker/External/ExternalMcpService.cs:264-300`
|
||||||
|
- Test: `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
Append inside the `ExternalMcpServiceTests` class. `SeedTaskAsync` does not exist in this class —
|
||||||
|
seed inline the way the existing tests do, via `_lists` / `_tasks`.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private async Task<TaskEntity> SeedPlainTaskAsync(TaskStatus status)
|
||||||
|
{
|
||||||
|
var listId = Guid.NewGuid().ToString();
|
||||||
|
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
|
||||||
|
var task = new TaskEntity
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid().ToString(), ListId = listId, Title = "t",
|
||||||
|
Status = status, CreatedAt = DateTime.UtcNow, CommitType = "chore",
|
||||||
|
};
|
||||||
|
await _tasks.AddAsync(task);
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateTaskStatus_Cancelled_CancelsAnIdleTask()
|
||||||
|
{
|
||||||
|
var task = await SeedPlainTaskAsync(TaskStatus.Idle);
|
||||||
|
var queue = CreateQueue();
|
||||||
|
var sut = BuildSut(queue);
|
||||||
|
|
||||||
|
var dto = await sut.UpdateTaskStatus(task.Id, "Cancelled", CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal("Cancelled", dto.Status);
|
||||||
|
var loaded = await _tasks.GetByIdAsync(task.Id);
|
||||||
|
Assert.Equal(TaskStatus.Cancelled, loaded!.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateTaskStatus_Done_StillRejected()
|
||||||
|
{
|
||||||
|
var task = await SeedPlainTaskAsync(TaskStatus.Idle);
|
||||||
|
var queue = CreateQueue();
|
||||||
|
var sut = BuildSut(queue);
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => sut.UpdateTaskStatus(task.Id, "Done", CancellationToken.None));
|
||||||
|
Assert.Contains("not settable externally", ex.Message);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the tests to verify they fail**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release \
|
||||||
|
--filter "FullyQualifiedName~ExternalMcpServiceTests.UpdateTaskStatus"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `UpdateTaskStatus_Cancelled_CancelsAnIdleTask` FAILS with
|
||||||
|
`Status 'Cancelled' is not settable externally.`; `UpdateTaskStatus_Done_StillRejected` passes.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the `Cancelled` branch**
|
||||||
|
|
||||||
|
In `ExternalMcpService.UpdateTaskStatus`, insert between the `Queued` case and `default`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
case TaskStatus.Cancelled:
|
||||||
|
var cancelResult = await _state.CancelAsync(taskId, DateTime.UtcNow, cancellationToken);
|
||||||
|
if (!cancelResult.Ok)
|
||||||
|
throw new InvalidOperationException(cancelResult.Reason ?? "Cannot cancel task.");
|
||||||
|
break;
|
||||||
|
```
|
||||||
|
|
||||||
|
Then update the `[McpServerTool, Description(...)]` text directly above the method — it currently
|
||||||
|
claims only Idle and Queued are permitted. Replace the whole attribute with:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[McpServerTool, Description(
|
||||||
|
"Update a task's status. Only 'Idle', 'Queued' and 'Cancelled' are permitted externally — " +
|
||||||
|
"use run_task_now for execution control, and review_task to act on a WaitingForReview task. " +
|
||||||
|
"Settable: Idle (reset to editable), Queued (enqueue for execution), " +
|
||||||
|
"Cancelled (retire the task without deleting it; it can be reset to Idle later). " +
|
||||||
|
"Full lifecycle: Idle → Queued → Running → WaitingForReview → Done | Failed | Cancelled.")]
|
||||||
|
```
|
||||||
|
|
||||||
|
Also fix the `default` branch message, which still points at `cancel_task`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
default:
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Status '{target}' is not settable externally. Use run_task_now or review_task.");
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the tests to verify they pass**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release \
|
||||||
|
--filter "FullyQualifiedName~ExternalMcpServiceTests"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all pass.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run the MCP schema test**
|
||||||
|
|
||||||
|
`ExternalMcpToolSchemaTests` asserts over tool descriptions and may pin the old text.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release \
|
||||||
|
--filter "FullyQualifiedName~ExternalMcpToolSchemaTests"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS. If it fails on the changed description, update the assertion to match the new
|
||||||
|
text — do not revert the description.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -- src/ClaudeDo.Worker/External/ExternalMcpService.cs tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs
|
||||||
|
git commit -m "feat(worker): allow update_task_status to set Cancelled" -- src/ClaudeDo.Worker/External/ExternalMcpService.cs tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs
|
||||||
|
```
|
||||||
|
|
||||||
|
(If Step 5 required a schema-test edit, add that path to both commands too.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Five-phase helper prompt
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Data/PromptFiles.cs:231-276` (`MergeHelperDefault`, `MergeHelperInitialDefault`)
|
||||||
|
- Test: `tests/ClaudeDo.Data.Tests/PromptFilesTests.cs:54-80`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
Replace the existing `DefaultFor_merge_helper_is_non_empty_and_mentions_the_merge_tools` test with
|
||||||
|
the two below, and keep the other merge-helper tests as they are.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public void DefaultFor_merge_helper_covers_all_five_phases()
|
||||||
|
{
|
||||||
|
var d = PromptFiles.DefaultFor(PromptKind.MergeHelper);
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(d));
|
||||||
|
Assert.Contains("Phase 0", d);
|
||||||
|
Assert.Contains("Phase 1", d);
|
||||||
|
Assert.Contains("Phase 2", d);
|
||||||
|
Assert.Contains("Phase 3", d);
|
||||||
|
Assert.Contains("Phase 4", d);
|
||||||
|
Assert.Contains("Phase 5", d);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DefaultFor_merge_helper_names_the_tools_each_phase_needs()
|
||||||
|
{
|
||||||
|
var d = PromptFiles.DefaultFor(PromptKind.MergeHelper);
|
||||||
|
Assert.Contains("batch_get_tasks", d); // phase 0
|
||||||
|
Assert.Contains("update_task", d); // phase 1 + 2
|
||||||
|
Assert.Contains("get_app_settings", d); // phase 3
|
||||||
|
Assert.Contains("update_task_status", d); // phase 3
|
||||||
|
Assert.Contains("review_task", d); // phase 4
|
||||||
|
Assert.Contains("continue_merge", d); // phase 4
|
||||||
|
Assert.DoesNotContain("run_task_now(", d); // single override slot — must not batch-start
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the tests to verify they fail**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release \
|
||||||
|
--filter "FullyQualifiedName~PromptFilesTests"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: both new tests FAIL (no "Phase 0", no `batch_get_tasks`).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Replace `MergeHelperDefault`**
|
||||||
|
|
||||||
|
Replace the whole `private const string MergeHelperDefault = """ … """;` block with:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private const string MergeHelperDefault = """
|
||||||
|
You are the ClaudeDo list handler, running as an interactive session with the user watching. Ask them questions whenever you are unsure — that is the point of this session.
|
||||||
|
|
||||||
|
Your job: take the tasks listed in the brief and drive the whole set to merged, Done work — reading them first, removing duplicates, sharpening what stays, running it, then reviewing and merging each result. You act through the mcp__claudedo__* tools. Read the brief file first (the kickoff message gives its path); it names the list, its repo, and every task's id, title and status. All tasks belong to that one list and one repo.
|
||||||
|
|
||||||
|
Work the five phases in order. Do not start a phase before the previous one is finished.
|
||||||
|
|
||||||
|
## Phase 0 — Read everything
|
||||||
|
Call batch_get_tasks with every id from the brief and read each task's title, description, status and parent/child links. Do not act on any single task before you have read them all — Phase 1 needs the whole set in view.
|
||||||
|
|
||||||
|
## Phase 1 — Dedupe
|
||||||
|
Compare the tasks pairwise for overlap: same goal stated twice, one task fully contained in another, two tasks that would edit the same thing for the same reason.
|
||||||
|
|
||||||
|
Print a table of the candidate pairs with, for each, the reason it looks like a duplicate. Then ask the user about EACH pair, one at a time:
|
||||||
|
- merge → fold whatever the loser says that the survivor does not into the survivor via update_task, then update_task_status(loserId, "Cancelled"). Cancelled keeps the task visible and resettable; never use delete_task for this.
|
||||||
|
- keep both → note why and move on.
|
||||||
|
|
||||||
|
Cancel nothing without an explicit answer. If there are no duplicates, say so and go on.
|
||||||
|
|
||||||
|
## Phase 2 — Enhance for execution
|
||||||
|
Each surviving task is about to be run by an autonomous agent with no further input. Sharpen it so that run can succeed. For each task, rewrite title and description to carry:
|
||||||
|
- concrete acceptance criteria — what must be true when it is done,
|
||||||
|
- the files and areas actually involved, found with Read/Grep/Glob in the repo. Do not guess paths; look them up.
|
||||||
|
- what is explicitly out of scope.
|
||||||
|
|
||||||
|
Write it back with update_task (title, description and commitType are the settable fields).
|
||||||
|
|
||||||
|
Rules: do not change what the user asked for, and do not invent requirements. You are making the existing intent precise, not adding to it. If a task is too vague to sharpen without guessing, ASK instead of guessing. Report a short before/after per task.
|
||||||
|
|
||||||
|
## Phase 3 — Run
|
||||||
|
Do NOT use run_task_now for a batch — there is a single override slot and the second call fails with "override slot busy".
|
||||||
|
|
||||||
|
Read get_app_settings and tell the user how many parallel execution slots are configured (maxParallelExecutions). If it is 1, say plainly that the tasks will execute one after another and that the value is changeable in ClaudeDo's settings.
|
||||||
|
|
||||||
|
Then, for each surviving task:
|
||||||
|
- Idle or Failed → update_task_status(id, "Queued"). For a Failed task ask first whether to reset_failed_task and re-queue it, or skip it.
|
||||||
|
- Queued → leave it; it is already waiting for a slot.
|
||||||
|
- Running or WaitingForChildren → leave it; only poll.
|
||||||
|
- WaitingForReview → leave it; it goes straight to Phase 4.
|
||||||
|
|
||||||
|
Poll get_task until every task has left Queued and Running — WaitingForReview on success, Failed on error. Report progress as tasks land; do not poll silently for minutes.
|
||||||
|
|
||||||
|
## Phase 4 — Review and merge
|
||||||
|
One task at a time, in the order the brief lists them.
|
||||||
|
|
||||||
|
1. Inspect the change with get_task_diff (stat first, then the full diff if it is non-trivial) and sanity-check it against the task's title and description.
|
||||||
|
2. If the change looks wrong, incomplete, or risky, STOP and ask the user before merging — offer reject_rerun (with feedback) or skip.
|
||||||
|
3. Otherwise merge with review_task(taskId, decision="approve", leaveConflictsInTree=true).
|
||||||
|
- Clean merge → the task is Done; move on.
|
||||||
|
- Conflict (markers left in the working tree, repoPath returned) → resolve it.
|
||||||
|
|
||||||
|
Every branch in this run forked from the same base, so conflicts between them are the NORMAL case, not a failure. Resolve them and keep going; do not abandon the run because a merge conflicted.
|
||||||
|
|
||||||
|
Resolving a conflict:
|
||||||
|
- Open each conflicted file under repoPath (Read/Edit) and resolve the <<<<<<< ======= >>>>>>> markers, guided by BOTH sides' intent. Then call continue_merge(taskId). If markers remain it tells you — fix and call again. Use abort_merge(taskId) to cancel a merge you cannot safely resolve.
|
||||||
|
- For a task WITH children (a unit merge), pass the PARENT task id to continue_merge / abort_merge.
|
||||||
|
- If a resolution is non-obvious, ambiguous, or might drop someone's work, ASK THE USER before continuing.
|
||||||
|
- Prefer the MCP tools whenever they apply. Only if the MCP tools cannot reach an in-progress merge may you finish it by hand: resolve the markers, then `git add -- <the resolved paths>` and `git commit` — NEVER `git add -A` or a bare commit, because the checkout is shared with other sessions.
|
||||||
|
|
||||||
|
Rules for the whole session:
|
||||||
|
- Never use raw `git merge`, `git reset`, or `git checkout` to force a merge. Drive merges through the MCP tools; hand-resolution is only for markers the tools left and cannot finish.
|
||||||
|
- Ask the user for anything ambiguous, risky, or destructive.
|
||||||
|
|
||||||
|
## Phase 5 — Summary
|
||||||
|
Print one line per task from the original brief:
|
||||||
|
title — dedupe action (kept / merged into X / cancelled as duplicate of X) — enhanced (yes/no) — final status — merge commit (if any) — conflicts resolved (if any).
|
||||||
|
|
||||||
|
Then list anything you skipped or left for the user and why, and any follow-ups worth turning into new tasks.
|
||||||
|
""";
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Replace `MergeHelperInitialDefault`**
|
||||||
|
|
||||||
|
The scope is now always one list with one repo, so the header states it once and the task lines
|
||||||
|
drop the constant `list:` / `repo:` fields.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private const string MergeHelperInitialDefault = """
|
||||||
|
# List handler brief
|
||||||
|
|
||||||
|
Scope: {scope}
|
||||||
|
Repo: {repo}
|
||||||
|
|
||||||
|
Handle the following tasks. Work Phases 0–5 as your instructions describe, asking me whenever you are unsure.
|
||||||
|
|
||||||
|
{tasks}
|
||||||
|
|
||||||
|
When every task is handled, print the summary.
|
||||||
|
""";
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Add the `{repo}` token test**
|
||||||
|
|
||||||
|
`{repo}` is a new token — Task 4 will pass it. Add to `PromptFilesTests`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public void DefaultFor_merge_helper_initial_has_repo_token()
|
||||||
|
{
|
||||||
|
var d = PromptFiles.DefaultFor(PromptKind.MergeHelperInitial);
|
||||||
|
Assert.Contains("{repo}", d);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The existing `RenderTemplate_merge_helper_initial_substitutes_scope_and_tasks` test passes only
|
||||||
|
`scope` and `tasks`. `RenderTemplate` leaves unknown tokens alone, so its two `Assert.Contains`
|
||||||
|
still hold and its `Assert.DoesNotContain("{scope}", outp)` still holds. Leave it unchanged.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Run the tests to verify they pass**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release \
|
||||||
|
--filter "FullyQualifiedName~PromptFilesTests"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all pass.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -- src/ClaudeDo.Data/PromptFiles.cs tests/ClaudeDo.Data.Tests/PromptFilesTests.cs
|
||||||
|
git commit -m "feat(data): five-phase list-handler prompt with dedupe and enhance" -- src/ClaudeDo.Data/PromptFiles.cs tests/ClaudeDo.Data.Tests/PromptFilesTests.cs
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Drop the global entry point and the LIST column
|
||||||
|
|
||||||
|
This removes every caller that passes a null `listId`, clearing the way for Task 4's signature
|
||||||
|
sweep. Types stay nullable here; only callers and UI go.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs:103-113`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml:184,206-210`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/MergeHelperSelectionModalViewModel.cs:14-53`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Views/Modals/MergeHelperSelectionModal.axaml:41-72`
|
||||||
|
- Modify: `src/ClaudeDo.Localization/locales/en.json`, `src/ClaudeDo.Localization/locales/de.json`
|
||||||
|
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/MergeHelperSelectionModalViewModelTests.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update the dialog tests to the list-only API**
|
||||||
|
|
||||||
|
`Configure` becomes `Configure(string listId, string listName)` and `IsGlobal` and `ListName` are
|
||||||
|
gone. Rewrite the affected tests. `Load_ExcludesTerminalStatuses_AndTicksActionableByDefault`,
|
||||||
|
`CanConfirm_FollowsRowSelection` and `Confirm_ReturnsSelectedIds_InRowOrder` all used
|
||||||
|
`Configure(null, null)` to see every seeded task — point them at `"L1"` instead, which holds all
|
||||||
|
eight seeded statuses (`t-other-list` lives in `L2` and drops out).
|
||||||
|
|
||||||
|
Replace the four tests below; leave `Load_NoCandidates_HasTasksFalse_CannotConfirm` untouched.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public async Task Load_ExcludesTerminalStatuses_AndTicksActionableByDefault()
|
||||||
|
{
|
||||||
|
await SeedAllStatusesAsync();
|
||||||
|
var vm = BuildVm();
|
||||||
|
vm.Configure("L1", "Work");
|
||||||
|
await vm.LoadAsync();
|
||||||
|
|
||||||
|
Assert.DoesNotContain(vm.Tasks, t => t.Id is "t-done" or "t-cancelled");
|
||||||
|
Assert.DoesNotContain(vm.Tasks, t => t.Id == "t-other-list");
|
||||||
|
Assert.Equal(6, vm.Tasks.Count);
|
||||||
|
|
||||||
|
Assert.True(vm.Tasks.Single(t => t.Id == "t-idle").IsSelected);
|
||||||
|
Assert.True(vm.Tasks.Single(t => t.Id == "t-queued").IsSelected);
|
||||||
|
Assert.True(vm.Tasks.Single(t => t.Id == "t-review").IsSelected);
|
||||||
|
Assert.True(vm.Tasks.Single(t => t.Id == "t-failed").IsSelected);
|
||||||
|
Assert.False(vm.Tasks.Single(t => t.Id == "t-running").IsSelected);
|
||||||
|
Assert.False(vm.Tasks.Single(t => t.Id == "t-children").IsSelected);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Load_PerListScope_FiltersToThatList()
|
||||||
|
{
|
||||||
|
await SeedAllStatusesAsync();
|
||||||
|
var vm = BuildVm();
|
||||||
|
vm.Configure("L2", "Home");
|
||||||
|
await vm.LoadAsync();
|
||||||
|
|
||||||
|
Assert.Single(vm.Tasks);
|
||||||
|
Assert.Equal("t-other-list", vm.Tasks[0].Id);
|
||||||
|
Assert.Contains("Home", vm.ScopeLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CanConfirm_FollowsRowSelection()
|
||||||
|
{
|
||||||
|
await SeedAllStatusesAsync();
|
||||||
|
var vm = BuildVm();
|
||||||
|
vm.Configure("L1", "Work");
|
||||||
|
await vm.LoadAsync();
|
||||||
|
|
||||||
|
Assert.True(vm.CanConfirm);
|
||||||
|
|
||||||
|
vm.SelectNoneCommand.Execute(null);
|
||||||
|
Assert.False(vm.CanConfirm);
|
||||||
|
Assert.All(vm.Tasks, t => Assert.False(t.IsSelected));
|
||||||
|
|
||||||
|
vm.Tasks[0].IsSelected = true; // single row re-enables via PropertyChanged hook
|
||||||
|
Assert.True(vm.CanConfirm);
|
||||||
|
|
||||||
|
vm.SelectAllCommand.Execute(null);
|
||||||
|
Assert.All(vm.Tasks, t => Assert.True(t.IsSelected));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Confirm_ReturnsSelectedIds_InRowOrder()
|
||||||
|
{
|
||||||
|
await SeedAllStatusesAsync();
|
||||||
|
var vm = BuildVm();
|
||||||
|
vm.Configure("L1", "Work");
|
||||||
|
await vm.LoadAsync();
|
||||||
|
|
||||||
|
vm.SelectNoneCommand.Execute(null);
|
||||||
|
vm.Tasks.Single(t => t.Id == "t-review").IsSelected = true;
|
||||||
|
vm.Tasks.Single(t => t.Id == "t-idle").IsSelected = true;
|
||||||
|
|
||||||
|
var closed = false;
|
||||||
|
vm.CloseAction = () => closed = true;
|
||||||
|
vm.ConfirmCommand.Execute(null);
|
||||||
|
|
||||||
|
var result = await vm.Result.Task;
|
||||||
|
Assert.NotNull(result);
|
||||||
|
// Row order (SortOrder): t-idle was seeded before t-review.
|
||||||
|
Assert.Equal(new[] { "t-idle", "t-review" }, result);
|
||||||
|
Assert.True(closed);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Also change `Cancel_ReturnsNull`'s `vm.Configure(null, null);` to `vm.Configure("L1", "Work");`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the tests to verify they fail**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release \
|
||||||
|
--filter "FullyQualifiedName~MergeHelperSelectionModalViewModelTests"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL — the project does not compile, because `Configure(string, string)` does not exist
|
||||||
|
yet and `IsGlobal` was removed from an assertion that still compiles against it. Compilation
|
||||||
|
failure is the expected "red" here.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Make the dialog VM list-only**
|
||||||
|
|
||||||
|
In `MergeHelperSelectionModalViewModel.cs`:
|
||||||
|
|
||||||
|
Remove the `ListName` property from `MergeHelperTaskRowViewModel`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public sealed partial class MergeHelperTaskRowViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
public required string Id { get; init; }
|
||||||
|
public required string Title { get; init; }
|
||||||
|
public required string StatusText { get; init; }
|
||||||
|
|
||||||
|
[ObservableProperty] private bool _isSelected;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Change the field to non-nullable, drop `IsGlobal`, and make `Configure` list-only:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private string _listId = "";
|
||||||
|
```
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[ObservableProperty] private string _scopeLabel = "";
|
||||||
|
|
||||||
|
public bool HasTasks => Tasks.Count > 0;
|
||||||
|
```
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public void Configure(string listId, string listName)
|
||||||
|
{
|
||||||
|
_listId = listId;
|
||||||
|
ScopeLabel = Loc.T("modals.mergeHelper.scopeList", listName);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In `LoadAsync`, the list filter is now unconditional and `ListName` is no longer selected:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||||
|
var candidates = await ctx.Tasks.AsNoTracking()
|
||||||
|
.Where(t => t.Status != TaskStatus.Done && t.Status != TaskStatus.Cancelled)
|
||||||
|
.Where(t => t.ListId == _listId)
|
||||||
|
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
|
||||||
|
.Select(t => new { t.Id, t.Title, t.Status })
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
foreach (var c in candidates)
|
||||||
|
{
|
||||||
|
var row = new MergeHelperTaskRowViewModel
|
||||||
|
{
|
||||||
|
Id = c.Id,
|
||||||
|
Title = c.Title,
|
||||||
|
StatusText = c.Status.ToString(),
|
||||||
|
IsSelected = IsTickedByDefault(c.Status),
|
||||||
|
};
|
||||||
|
row.PropertyChanged += OnRowChanged;
|
||||||
|
Tasks.Add(row);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Drop the LIST column from the dialog view**
|
||||||
|
|
||||||
|
In `MergeHelperSelectionModal.axaml`, change both `ColumnDefinitions="32,*,120,120"` (lines 41 and
|
||||||
|
57) to `ColumnDefinitions="32,*,120"`, and delete the two `Grid.Column="3"` elements — the header
|
||||||
|
`TextBlock` bound to `modals.mergeHelper.columnList` (lines 45-46) and the row `TextBlock` bound to
|
||||||
|
`ListName` (lines 68-71).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Remove the global command and the Broom button**
|
||||||
|
|
||||||
|
In `ListsIslandViewModel.cs`, delete the whole `LetClaudeHandleAllAsync` method including its
|
||||||
|
`[RelayCommand]` attribute (lines 103-113). Leave `LetClaudeHandleListAsync` and the
|
||||||
|
`MergeHelperRequest` record as they are — Task 4 changes those.
|
||||||
|
|
||||||
|
In `ListsIslandView.axaml`, revert the button row to two columns:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<!-- New list + import row -->
|
||||||
|
<Grid ColumnDefinitions="*,Auto" Margin="0,4,0,0">
|
||||||
|
```
|
||||||
|
|
||||||
|
and delete the whole `<Button Grid.Column="2" … LetClaudeHandleAllCommand … />` element
|
||||||
|
(lines 206-210) including its `<PathIcon>` child.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Remove the three dead localization keys**
|
||||||
|
|
||||||
|
Delete from **both** `src/ClaudeDo.Localization/locales/en.json` and
|
||||||
|
`src/ClaudeDo.Localization/locales/de.json`:
|
||||||
|
|
||||||
|
- `lists.letClaudeAllTip` (and the trailing comma on the preceding key, so the object stays valid JSON)
|
||||||
|
- `modals.mergeHelper.scopeAll`
|
||||||
|
- `modals.mergeHelper.columnList` (and the trailing comma on the preceding key)
|
||||||
|
|
||||||
|
Keep `lists.contextLetClaude` and `modals.mergeHelper.scopeList`.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Build and run the tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
|
||||||
|
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
|
||||||
|
dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: build succeeds, all tests pass. The localization parity test is the one that catches a
|
||||||
|
key removed from only one of the two JSON files.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -- src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml src/ClaudeDo.Ui/ViewModels/Modals/MergeHelperSelectionModalViewModel.cs src/ClaudeDo.Ui/Views/Modals/MergeHelperSelectionModal.axaml src/ClaudeDo.Localization/locales/en.json src/ClaudeDo.Localization/locales/de.json tests/ClaudeDo.Ui.Tests/ViewModels/MergeHelperSelectionModalViewModelTests.cs
|
||||||
|
git commit -m "refactor(ui): scope \"Let Claude handle it\" to a single list" -- src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml src/ClaudeDo.Ui/ViewModels/Modals/MergeHelperSelectionModalViewModel.cs src/ClaudeDo.Ui/Views/Modals/MergeHelperSelectionModal.axaml src/ClaudeDo.Localization/locales/en.json src/ClaudeDo.Localization/locales/de.json tests/ClaudeDo.Ui.Tests/ViewModels/MergeHelperSelectionModalViewModelTests.cs
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Non-nullable `listId` and a single-repo launch spec
|
||||||
|
|
||||||
|
One atomic commit across Worker and Ui. Splitting it would leave one side passing `string?` into a
|
||||||
|
`string` parameter — nullable warnings strewn across a commit boundary, and a launch spec that
|
||||||
|
still carries a dead multi-repo path.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Runner/Interfaces/IInteractiveLaunchSpecService.cs:36`
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs:166-253`
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs:682-687`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs:90`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs:525-526`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs:325-357`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs:20`
|
||||||
|
- Modify: `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs:103`
|
||||||
|
- Modify: `tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs:78`
|
||||||
|
- Test: `tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs:363-472`
|
||||||
|
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs:409-463`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Rewrite the launch-spec tests**
|
||||||
|
|
||||||
|
In `InteractiveLaunchSpecServiceTests.cs`, replace the four merge-helper `[Fact]`s (from
|
||||||
|
`BuildForMergeHelperAsync_EmptyTaskIds_ThrowsInvalidOperation` through
|
||||||
|
`BuildForMergeHelperAsync_WithListId_UsesListWorkingDirAsCwdAndListScope`) with these five. Keep
|
||||||
|
the `_mergeHelperSessionDirs` field and `TrackSessionDir` helper above them exactly as they are.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public async Task BuildForMergeHelperAsync_EmptyTaskIds_ThrowsInvalidOperation()
|
||||||
|
{
|
||||||
|
var listId = await SeedListAsync(workingDir: _tempDir);
|
||||||
|
var svc = BuildService();
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => svc.BuildForMergeHelperAsync(Array.Empty<string>(), listId, CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BuildForMergeHelperAsync_ListWithoutExistingWorkingDir_ThrowsInvalidOperation()
|
||||||
|
{
|
||||||
|
var listId = await SeedListAsync(workingDir: Path.Combine(_tempDir, "gone"));
|
||||||
|
var taskId = Guid.NewGuid().ToString();
|
||||||
|
await SeedTaskAsync(taskId, listId, TaskStatus.WaitingForReview);
|
||||||
|
|
||||||
|
var svc = BuildService();
|
||||||
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => svc.BuildForMergeHelperAsync(new[] { taskId }, listId, CancellationToken.None));
|
||||||
|
Assert.Contains("working directory", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BuildForMergeHelperAsync_UnknownList_Throws()
|
||||||
|
{
|
||||||
|
var listId = await SeedListAsync(workingDir: _tempDir);
|
||||||
|
var taskId = Guid.NewGuid().ToString();
|
||||||
|
await SeedTaskAsync(taskId, listId, TaskStatus.Idle);
|
||||||
|
|
||||||
|
var svc = BuildService();
|
||||||
|
await Assert.ThrowsAsync<KeyNotFoundException>(
|
||||||
|
() => svc.BuildForMergeHelperAsync(new[] { taskId }, "no-such-list", CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BuildForMergeHelperAsync_BuildsListScopedSpecWithSingleRepo()
|
||||||
|
{
|
||||||
|
var repo = Path.Combine(_tempDir, "repoOnly");
|
||||||
|
Directory.CreateDirectory(repo);
|
||||||
|
|
||||||
|
var listId = await SeedListAsync(workingDir: repo, name: "Alpha");
|
||||||
|
var t1 = Guid.NewGuid().ToString();
|
||||||
|
var t2 = Guid.NewGuid().ToString();
|
||||||
|
await SeedTaskAsync(t1, listId, TaskStatus.WaitingForReview, title: "First task");
|
||||||
|
await SeedTaskAsync(t2, listId, TaskStatus.Idle, title: "Second task");
|
||||||
|
|
||||||
|
var svc = BuildService();
|
||||||
|
var spec = await svc.BuildForMergeHelperAsync(new[] { t1, t2 }, listId, CancellationToken.None);
|
||||||
|
var sessionDir = TrackSessionDir(spec);
|
||||||
|
|
||||||
|
Assert.Equal(repo, spec.Cwd);
|
||||||
|
Assert.Equal(_claudeStubPath, spec.Exe);
|
||||||
|
|
||||||
|
var args = spec.Args.ToList();
|
||||||
|
|
||||||
|
var pmIdx = args.IndexOf("--permission-mode");
|
||||||
|
Assert.True(pmIdx >= 0);
|
||||||
|
Assert.Equal("default", args[pmIdx + 1]);
|
||||||
|
|
||||||
|
var atIdx = args.IndexOf("--allowedTools");
|
||||||
|
Assert.Equal("mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill", args[atIdx + 1]);
|
||||||
|
|
||||||
|
// --add-dir: session dir + the list's single repo dir
|
||||||
|
var addIdx = args.IndexOf("--add-dir");
|
||||||
|
var appendIdx = args.IndexOf("--append-system-prompt-file");
|
||||||
|
var addDirs = args.GetRange(addIdx + 1, appendIdx - addIdx - 1);
|
||||||
|
Assert.Equal(new[] { sessionDir, repo }, addDirs);
|
||||||
|
|
||||||
|
var systemPromptPath = args[appendIdx + 1];
|
||||||
|
Assert.Equal(Path.Combine(sessionDir, "system-prompt.md"), systemPromptPath);
|
||||||
|
Assert.True(File.Exists(systemPromptPath));
|
||||||
|
|
||||||
|
// kickoff is the LAST arg (positional), single line, points at brief.md
|
||||||
|
var kickoff = args[^1];
|
||||||
|
var briefPath = Path.Combine(sessionDir, "brief.md");
|
||||||
|
Assert.Contains(briefPath, kickoff);
|
||||||
|
Assert.DoesNotContain('\n', kickoff);
|
||||||
|
|
||||||
|
Assert.Equal("200000", spec.Env["MCP_TOOL_TIMEOUT"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BuildForMergeHelperAsync_BriefNamesListRepoAndEveryTask()
|
||||||
|
{
|
||||||
|
var repo = Path.Combine(_tempDir, "repoBrief");
|
||||||
|
Directory.CreateDirectory(repo);
|
||||||
|
|
||||||
|
var listId = await SeedListAsync(workingDir: repo, name: "Alpha");
|
||||||
|
var t1 = Guid.NewGuid().ToString();
|
||||||
|
var t2 = Guid.NewGuid().ToString();
|
||||||
|
await SeedTaskAsync(t1, listId, TaskStatus.WaitingForReview, title: "First task");
|
||||||
|
await SeedTaskAsync(t2, listId, TaskStatus.Idle, title: "Second task");
|
||||||
|
|
||||||
|
var svc = BuildService();
|
||||||
|
var spec = await svc.BuildForMergeHelperAsync(new[] { t1, t2 }, listId, CancellationToken.None);
|
||||||
|
var sessionDir = TrackSessionDir(spec);
|
||||||
|
|
||||||
|
var brief = File.ReadAllText(Path.Combine(sessionDir, "brief.md"));
|
||||||
|
Assert.Contains("Scope: List: Alpha", brief);
|
||||||
|
Assert.Contains($"Repo: {repo}", brief);
|
||||||
|
Assert.Contains("First task", brief);
|
||||||
|
Assert.Contains("Second task", brief);
|
||||||
|
Assert.Contains(t1, brief);
|
||||||
|
Assert.Contains(t2, brief);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the launch-spec tests to verify they fail**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release \
|
||||||
|
--filter "FullyQualifiedName~InteractiveLaunchSpecServiceTests.BuildForMergeHelper"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL. `BuildForMergeHelperAsync_UnknownList_Throws` fails because the current code
|
||||||
|
resolves the repo from the tasks and never validates the list; the brief test fails on the missing
|
||||||
|
`Repo:` line.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Rewrite `BuildForMergeHelperAsync`**
|
||||||
|
|
||||||
|
Replace the method body (`InteractiveLaunchSpecService.cs:166-253`) with:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public async Task<LaunchSpec> BuildForMergeHelperAsync(IReadOnlyList<string> taskIds, string listId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (taskIds.Count == 0)
|
||||||
|
throw new InvalidOperationException("No tasks selected for the list handler.");
|
||||||
|
|
||||||
|
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||||
|
var taskRepo = new TaskRepository(ctx);
|
||||||
|
var listRepo = new ListRepository(ctx);
|
||||||
|
|
||||||
|
var list = await listRepo.GetByIdAsync(listId, ct)
|
||||||
|
?? throw new KeyNotFoundException($"List not found: {listId}");
|
||||||
|
|
||||||
|
var repoDir = list.WorkingDir;
|
||||||
|
if (string.IsNullOrEmpty(repoDir) || !Directory.Exists(repoDir))
|
||||||
|
throw new InvalidOperationException($"list '{list.Name}' has no existing working directory");
|
||||||
|
|
||||||
|
var briefLines = new List<string>();
|
||||||
|
foreach (var id in taskIds)
|
||||||
|
{
|
||||||
|
var task = await taskRepo.GetByIdAsync(id, ct)
|
||||||
|
?? throw new KeyNotFoundException($"Task not found: {id}");
|
||||||
|
briefLines.Add($"- [{task.Status}] {task.Title} (id: {task.Id})");
|
||||||
|
}
|
||||||
|
|
||||||
|
var sessionDir = Path.Combine(Paths.AppDataRoot(), "merge-helper-sessions", Guid.NewGuid().ToString());
|
||||||
|
Directory.CreateDirectory(sessionDir);
|
||||||
|
|
||||||
|
var systemPromptPath = Path.Combine(sessionDir, "system-prompt.md");
|
||||||
|
await File.WriteAllTextAsync(systemPromptPath, PromptFiles.ReadOrDefault(PromptKind.MergeHelper), ct);
|
||||||
|
|
||||||
|
var briefPath = Path.Combine(sessionDir, "brief.md");
|
||||||
|
await File.WriteAllTextAsync(briefPath, PromptFiles.Render(PromptKind.MergeHelperInitial,
|
||||||
|
new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["scope"] = $"List: {list.Name}",
|
||||||
|
["repo"] = repoDir,
|
||||||
|
["tasks"] = string.Join("\n", briefLines),
|
||||||
|
}), ct);
|
||||||
|
|
||||||
|
var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath)
|
||||||
|
?? throw new InvalidOperationException($"claude executable not found: {_claudePath}");
|
||||||
|
|
||||||
|
// Mirrors WindowsTerminalLauncher.BuildPlanningStartArgs ordering: variadic flags
|
||||||
|
// (--allowedTools, --add-dir) first, then a single-value flag, then the single-line
|
||||||
|
// positional kickoff LAST — a multi-line positional prompt truncates at the first
|
||||||
|
// newline, so the full multi-line brief travels via the file exposed through --add-dir.
|
||||||
|
var args = new List<string>
|
||||||
|
{
|
||||||
|
"--permission-mode", "default",
|
||||||
|
"--allowedTools", MergeHelperAllowedTools,
|
||||||
|
"--add-dir", sessionDir, repoDir,
|
||||||
|
"--append-system-prompt-file", systemPromptPath,
|
||||||
|
$"Read the file {briefPath} first. It lists the tasks you must handle and their status. " +
|
||||||
|
"After reading it, begin the session as your instructions describe.",
|
||||||
|
};
|
||||||
|
|
||||||
|
var env = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["MCP_TOOL_TIMEOUT"] = "200000",
|
||||||
|
};
|
||||||
|
|
||||||
|
return new LaunchSpec(cwd: repoDir, resolvedClaude, args, env);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Flip the interface and hub signatures**
|
||||||
|
|
||||||
|
`src/ClaudeDo.Worker/Runner/Interfaces/IInteractiveLaunchSpecService.cs:36`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
Task<LaunchSpec> BuildForMergeHelperAsync(IReadOnlyList<string> taskIds, string listId, CancellationToken ct);
|
||||||
|
```
|
||||||
|
|
||||||
|
`src/ClaudeDo.Worker/Hub/WorkerHub.cs:682`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public Task<LaunchSpec> GetMergeHelperLaunchSpec(string[] taskIds, string listId) => HubGuard(() =>
|
||||||
|
```
|
||||||
|
|
||||||
|
(leave the method body as it is).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run the Worker tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
|
||||||
|
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release \
|
||||||
|
--filter "FullyQualifiedName~InteractiveLaunchSpecServiceTests"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: build succeeds, all pass. `TasksIslandViewModelPlanningTests.cs:78` holds a fake
|
||||||
|
implementing `IWorkerClient` — its signature is flipped in Step 7; if the Worker.Tests build fails
|
||||||
|
there, do Step 7 first and re-run.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Update the Mission Control tests**
|
||||||
|
|
||||||
|
In `MissionControlViewModelTests.cs`, the four `OpenMergeHelperConPtySessionAsync` calls pass
|
||||||
|
`null` as the list id. Replace `null` with `"L1"` on lines 421, 435, 436 and 461, and change the
|
||||||
|
`ThrowingMergeHelperLaunchSpecWorker` override signature at line 411 to:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public override Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, CancellationToken ct = default)
|
||||||
|
```
|
||||||
|
|
||||||
|
The list-title lookup in `OpenMergeHelperConPtySessionAsync` is wrapped in a `try/catch` and falls
|
||||||
|
back to the plain title, so an unseeded `"L1"` is harmless.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Flip the UI signatures**
|
||||||
|
|
||||||
|
`src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs:90`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, CancellationToken ct = default);
|
||||||
|
```
|
||||||
|
|
||||||
|
`src/ClaudeDo.Ui/Services/WorkerClient.cs:525`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public async Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, CancellationToken ct = default)
|
||||||
|
=> await _hub.InvokeAsync<LaunchSpec>("GetMergeHelperLaunchSpec", taskIds, listId, ct);
|
||||||
|
```
|
||||||
|
|
||||||
|
`tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs:103` and
|
||||||
|
`tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs:78` — same parameter change
|
||||||
|
(`string? listId` → `string listId`), bodies unchanged.
|
||||||
|
|
||||||
|
`src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs:20`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
/// <summary>Confirmed handler run: the scope list and the ordered selected task ids.</summary>
|
||||||
|
public sealed record MergeHelperRequest(string ListId, IReadOnlyList<string> TaskIds);
|
||||||
|
```
|
||||||
|
|
||||||
|
`src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs:325-341` — the `listId is not null` guard is
|
||||||
|
now dead:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// List-handler session over a hand-picked set of tasks ("Let Claude handle it").
|
||||||
|
// Ad-hoc style: no owning task, never deduped — every run opens a fresh pane.
|
||||||
|
public async System.Threading.Tasks.Task OpenMergeHelperConPtySessionAsync(string listId, IReadOnlyList<string> taskIds)
|
||||||
|
{
|
||||||
|
if (taskIds is not { Count: > 0 }) return;
|
||||||
|
|
||||||
|
var title = Loc.T("missionControl.mergeHelperTitle");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||||
|
var list = await ctx.Lists.AsNoTracking().FirstOrDefaultAsync(l => l.Id == listId);
|
||||||
|
if (list?.Name is { Length: > 0 } name) title = $"{title} — {name}";
|
||||||
|
}
|
||||||
|
catch { /* best-effort title lookup */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
Leave the rest of the method (the `try` block that fetches the spec and adds the pane) unchanged.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Build everything and run the full suite**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
|
||||||
|
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
|
||||||
|
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
|
||||||
|
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
|
||||||
|
dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release
|
||||||
|
dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: both builds succeed with no `CS8600`/`CS8604` nullability warnings on the touched files,
|
||||||
|
and every test passes.
|
||||||
|
|
||||||
|
- [ ] **Step 9: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -- src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs src/ClaudeDo.Worker/Runner/Interfaces/IInteractiveLaunchSpecService.cs src/ClaudeDo.Worker/Hub/WorkerHub.cs src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs src/ClaudeDo.Ui/Services/WorkerClient.cs src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs
|
||||||
|
git commit -m "refactor(worker): make the list-handler launch spec single-list and single-repo" -- src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs src/ClaudeDo.Worker/Runner/Interfaces/IInteractiveLaunchSpecService.cs src/ClaudeDo.Worker/Hub/WorkerHub.cs src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs src/ClaudeDo.Ui/Services/WorkerClient.cs src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Update the project docs
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/CLAUDE.md`
|
||||||
|
- Modify: `src/ClaudeDo.Worker/CLAUDE.md`
|
||||||
|
- Modify: `docs/open.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Check what the CLAUDE.md files claim**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -n "merge.helper\|Let Claude handle\|MergeHelper" src/ClaudeDo.Ui/CLAUDE.md src/ClaudeDo.Worker/CLAUDE.md docs/open.md
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Correct any stale claim**
|
||||||
|
|
||||||
|
Where those files describe the merge helper as having a global scope, or describe the prompt as
|
||||||
|
run-and-merge only, update them to: list-scoped only, single repo, five phases (read, dedupe,
|
||||||
|
enhance, queue, review+merge). Note in `src/ClaudeDo.Worker/CLAUDE.md` that
|
||||||
|
`update_task_status` now also accepts `Cancelled`. Do not restructure the files beyond that.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the open verification items**
|
||||||
|
|
||||||
|
Append to the open-items section of `docs/open.md`:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
- **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
|
||||||
|
LIST column. Plus a real-Claude smoke run of the five phases (dedupe questions, enhancements
|
||||||
|
landing in task descriptions, queued execution, merges).
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -- src/ClaudeDo.Ui/CLAUDE.md src/ClaudeDo.Worker/CLAUDE.md docs/open.md
|
||||||
|
git commit -m "docs: describe the list-scoped five-phase handler" -- src/ClaudeDo.Ui/CLAUDE.md src/ClaudeDo.Worker/CLAUDE.md docs/open.md
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification left to the user
|
||||||
|
|
||||||
|
None of this can be confirmed from tests alone:
|
||||||
|
|
||||||
|
- The lists footer no longer shows the Broom button, and the row context menu still offers
|
||||||
|
"Let Claude handle it" for lists with a working dir.
|
||||||
|
- The selection dialog shows TASK and STATUS only, and the scope line reads `List: <name>`.
|
||||||
|
- A real ConPTY run: Phase 1 asks about duplicates, Phase 2's enhancements are visible in the task
|
||||||
|
descriptions afterwards, Phase 3 reports the slot count and the tasks execute, Phase 4 merges or
|
||||||
|
hands off to conflict resolution.
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
# "Let Claude handle it" — per-list handler with read / dedupe / enhance / run / merge
|
||||||
|
|
||||||
|
Date: 2026-07-27
|
||||||
|
Supersedes parts of: `2026-07-24-merge-helper-design.md` (global scope, run-then-merge-only prompt)
|
||||||
|
|
||||||
|
## 1. Problem
|
||||||
|
|
||||||
|
The merge helper shipped in v2.3.0 with two entry points — a per-list context-menu item and a
|
||||||
|
global footer Broom button — and a prompt that only runs and merges the selected tasks.
|
||||||
|
|
||||||
|
Two things are wrong with that:
|
||||||
|
|
||||||
|
- **The global scope is unwanted.** A run spanning several lists spans several repos, which
|
||||||
|
makes `cwd`, the `--add-dir` set and the merge order ambiguous for no benefit. The user
|
||||||
|
works one list (= one repo) at a time.
|
||||||
|
- **The helper starts too late.** It takes the task list as given: it never reads the tasks as
|
||||||
|
a set, so duplicates run twice and produce conflicting worktrees, and vague tasks go into an
|
||||||
|
autonomous run under-specified and come back wrong.
|
||||||
|
|
||||||
|
## 2. Goal
|
||||||
|
|
||||||
|
One entry point, on a user list. It opens an interactive ConPTY session that takes the selected
|
||||||
|
tasks through five phases: read them all, dedupe them, sharpen them for autonomous execution,
|
||||||
|
run them, then review and merge each worktree.
|
||||||
|
|
||||||
|
Non-goals: no change to the autonomous queue path, no change to the ConPTY tile plumbing,
|
||||||
|
no rename of the `MergeHelper*` identifiers (the user-facing label stays "Let Claude handle it").
|
||||||
|
|
||||||
|
## 3. Scope becomes list-only
|
||||||
|
|
||||||
|
`listId` becomes non-nullable across the whole chain:
|
||||||
|
|
||||||
|
| Layer | Change |
|
||||||
|
|---|---|
|
||||||
|
| `ListsIslandViewModel` | `MergeHelperRequest(string ListId, …)`; `LetClaudeHandleAllAsync` deleted |
|
||||||
|
| `IslandsShellViewModel:241` | unchanged (already forwards `req.ListId`) |
|
||||||
|
| `MissionControlViewModel:327` | `OpenMergeHelperConPtySessionAsync(string listId, …)` |
|
||||||
|
| `IWorkerClient:90` / `WorkerClient:525` | `GetMergeHelperLaunchSpecAsync(taskIds, string listId, ct)` |
|
||||||
|
| `WorkerHub:682` | `GetMergeHelperLaunchSpec(string[] taskIds, string listId)` |
|
||||||
|
| `IInteractiveLaunchSpecService:36` | `BuildForMergeHelperAsync(taskIds, string listId, ct)` |
|
||||||
|
|
||||||
|
Deleted UI surface:
|
||||||
|
|
||||||
|
- Broom button `ListsIslandView.axaml:205-210` and `LetClaudeHandleAllCommand`.
|
||||||
|
- `IsGlobal` on `MergeHelperSelectionModalViewModel` and the LIST column
|
||||||
|
(`MergeHelperSelectionModal.axaml:71`) — with a single list the column is constant.
|
||||||
|
- Localization keys `lists.letClaudeAllTip`, `modals.mergeHelper.scopeAll`,
|
||||||
|
`modals.mergeHelper.columnList` (en + de, parity test enforces both).
|
||||||
|
|
||||||
|
`Configure(string listId, string listName)` loses its nullable overload; `ScopeLabel` always
|
||||||
|
renders `modals.mergeHelper.scopeList`.
|
||||||
|
|
||||||
|
### 3.1 Single repo in the launch spec
|
||||||
|
|
||||||
|
`BuildForMergeHelperAsync` currently collects a distinct `repoDirs` set across the selected
|
||||||
|
tasks and picks `cwd` per scope. With a list scope every task shares the list's `WorkingDir`,
|
||||||
|
so this collapses to:
|
||||||
|
|
||||||
|
- Load the list; throw `InvalidOperationException` if it has no existing `WorkingDir`.
|
||||||
|
- `cwd` = that directory; `--add-dir` = the session dir + that one directory.
|
||||||
|
- The per-task brief line drops the now-constant `list:` and `repo:` fields.
|
||||||
|
|
||||||
|
The context-menu item is already hidden when `WorkingDir` is empty, so the throw is a guard,
|
||||||
|
not a normal path.
|
||||||
|
|
||||||
|
### 3.2 Entry point visibility
|
||||||
|
|
||||||
|
The item stays in the list row's context menu, next to "List settings", "Worktrees overview",
|
||||||
|
"Open in Explorer" and "Open in Terminal". That is the established place for list-scoped
|
||||||
|
actions; a second always-visible button in the row would break the pattern.
|
||||||
|
|
||||||
|
## 4. Worker: `Cancelled` becomes externally settable
|
||||||
|
|
||||||
|
`ExternalMcpService.UpdateTaskStatus` accepts only `Idle` and `Queued` and throws
|
||||||
|
`"Status '{target}' is not settable externally. Use run_task_now or cancel_task."` for the
|
||||||
|
rest — but neither escape hatch reaches an **Idle** task: `cancel_task` only cancels a
|
||||||
|
*running* task, and `review_task(decision="cancel")` requires WaitingForReview/Running/Queued.
|
||||||
|
The dedupe phase needs exactly that: retire an Idle duplicate without destroying it.
|
||||||
|
|
||||||
|
Add to the switch:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
case TaskStatus.Cancelled:
|
||||||
|
var cancelResult = await _state.CancelAsync(taskId, DateTime.UtcNow, cancellationToken);
|
||||||
|
if (!cancelResult.Ok)
|
||||||
|
throw new InvalidOperationException(cancelResult.Reason ?? "Cannot cancel task.");
|
||||||
|
break;
|
||||||
|
```
|
||||||
|
|
||||||
|
`TaskStateService.CancelAsync` (`State/TaskStateService.cs:244`) already owns the transition
|
||||||
|
and its worktree/parent side effects. The existing error message in `UpdateTaskStatus` already
|
||||||
|
lists `Cancelled` as valid, so this also removes a lie. A cancelled task stays visible and can
|
||||||
|
be reset to Idle — nothing is lost, unlike `delete_task`.
|
||||||
|
|
||||||
|
## 5. The five-phase prompt
|
||||||
|
|
||||||
|
`PromptFiles.MergeHelperDefault` is rewritten. The session stays interactive and the helper is
|
||||||
|
told to ask whenever unsure — that is the point of a watched ConPTY session.
|
||||||
|
|
||||||
|
### Phase 0 — Read
|
||||||
|
|
||||||
|
`batch_get_tasks` over every id in the brief before touching anything: title, description,
|
||||||
|
status, parent/child links. The helper must hold the whole set in mind before acting on any
|
||||||
|
single task.
|
||||||
|
|
||||||
|
### Phase 1 — Dedupe
|
||||||
|
|
||||||
|
Compare the tasks pairwise for overlap. Emit a table of candidate pairs with the reason each
|
||||||
|
pair looks like a duplicate, then **ask per pair**:
|
||||||
|
|
||||||
|
- merge → fold the loser's unique content into the survivor via `update_task`, then
|
||||||
|
`update_task_status(loserId, "Cancelled")`;
|
||||||
|
- keep both → note why and move on.
|
||||||
|
|
||||||
|
Nothing is cancelled without an explicit answer.
|
||||||
|
|
||||||
|
### Phase 2 — Enhance
|
||||||
|
|
||||||
|
For each surviving task, sharpen title and description for autonomous execution:
|
||||||
|
|
||||||
|
- concrete acceptance criteria,
|
||||||
|
- the files/areas actually involved — grounded in the repo via Read/Grep/Glob, not guessed,
|
||||||
|
- explicit out-of-scope.
|
||||||
|
|
||||||
|
Write back with `update_task` (title / description / commitType are the settable fields; it
|
||||||
|
refuses while Running, which cannot happen this early). Rules: do not change intent, do not
|
||||||
|
invent requirements. A task too vague to sharpen safely gets a question, not a guess.
|
||||||
|
|
||||||
|
### Phase 3 — Run
|
||||||
|
|
||||||
|
`run_task_now` cannot be used for a batch: `OverrideSlotService.StartInSlot`
|
||||||
|
(`Queue/OverrideSlotService.cs:65-70`) holds a single slot and throws `"override slot busy"`
|
||||||
|
on the second concurrent call. The queue picker is the only parallel path.
|
||||||
|
|
||||||
|
So: read `get_app_settings`, tell the user how many parallel slots are configured
|
||||||
|
(`MaxParallelExecutions`, default 1 — `AppSettingsEntity.cs:14`), then
|
||||||
|
`update_task_status(id, "Queued")` for every surviving task, then poll `get_task` until each
|
||||||
|
has left Queued/Running — `WaitingForReview` on success, `Failed` on error. Announcing the
|
||||||
|
slot count up front stops the user wondering why "run them all" executes one at a time.
|
||||||
|
|
||||||
|
A task already `Running` or `WaitingForChildren` when the session starts is not re-queued, only
|
||||||
|
polled. A task already `WaitingForReview` skips straight to Phase 4.
|
||||||
|
|
||||||
|
### Phase 4 — Review and merge
|
||||||
|
|
||||||
|
Sequential, in list order. A task that came back `Failed` needs human judgement — ask whether
|
||||||
|
to `reset_failed_task` and re-queue it, or skip it. Otherwise, unchanged from the shipped
|
||||||
|
prompt: `get_task_diff` (stat first,
|
||||||
|
full diff when non-trivial), sanity-check against the task's intent, ask before merging
|
||||||
|
anything that looks wrong, then `review_task(taskId, decision="approve",
|
||||||
|
leaveConflictsInTree=true)` and the conflict loop (`continue_merge` / `abort_merge`, parent id
|
||||||
|
for unit merges, hand-resolution only where MCP cannot reach, always
|
||||||
|
`git commit -- <paths>` and never `git add -A` because the checkout is shared).
|
||||||
|
|
||||||
|
New in this phase: the prompt states that because Phase 3 branches all fork from the same
|
||||||
|
base, **conflicts are the normal case, not an exception** — resolve them rather than bailing
|
||||||
|
out of the run.
|
||||||
|
|
||||||
|
### Phase 5 — Summary
|
||||||
|
|
||||||
|
One line per task: title — dedupe action — enhanced? — final status — merge commit —
|
||||||
|
conflicts resolved. Then anything skipped and why, then follow-ups.
|
||||||
|
|
||||||
|
### Brief template
|
||||||
|
|
||||||
|
`MergeHelperInitialDefault` names the list and repo once in the header and drops the per-task
|
||||||
|
`list:`/`repo:` fields, leaving `- [{status}] {title} (id: {id})`. Descriptions stay out of the
|
||||||
|
brief; Phase 0 fetches them.
|
||||||
|
|
||||||
|
## 6. Testing
|
||||||
|
|
||||||
|
| Test | Change |
|
||||||
|
|---|---|
|
||||||
|
| `MergeHelperSelectionModalViewModelTests` | drop `IsGlobal`, list-scoped `Configure` |
|
||||||
|
| `InteractiveLaunchSpecServiceTests` | three `null`-listId cases → non-null; drop the two-repo global-scope test; add "list without WorkingDir throws" |
|
||||||
|
| `MissionControlViewModelTests` | four `OpenMergeHelperConPtySessionAsync(null, …)` calls |
|
||||||
|
| `StubWorkerClient`, `TasksIslandViewModelPlanningTests` fake | signature |
|
||||||
|
| `PromptFilesTests` | assert the five phase markers in the default prompt |
|
||||||
|
| `Localization.Tests` | parity after removing three keys |
|
||||||
|
| new: `ExternalMcpService` / worker test | `UpdateTaskStatus(id, "Cancelled")` cancels an Idle task; unknown status still throws |
|
||||||
|
|
||||||
|
No test spawns the real `claude` CLI — the prompt content is asserted as text, the session
|
||||||
|
itself is a manual smoke step.
|
||||||
|
|
||||||
|
## 7. Verification left to the user
|
||||||
|
|
||||||
|
- The list context menu shows "Let Claude handle it" only for lists with a working dir, and
|
||||||
|
the Broom button is gone from the footer row.
|
||||||
|
- The selection dialog has no LIST column and reads "List: <name>".
|
||||||
|
- A real ConPTY run: dedupe questions appear, enhancements land in the task descriptions,
|
||||||
|
queued tasks execute, merges complete or hand off to conflict resolution.
|
||||||
@@ -4,8 +4,8 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio
|
|||||||
|
|
||||||
## Models
|
## Models
|
||||||
|
|
||||||
- **TaskEntity** — Id, ListId, Title, Description, Status (`Idle|Queued|Running|WaitingForChildren|WaitingForReview|Done|Failed|Cancelled`), PlanningPhase (`None|Active|Finalized` — parent-only), BlockedByTaskId (nullable FK to predecessor in a chain), ScheduledFor, Result, ReviewFeedback (nullable; reviewer's rejection comment, consumed and cleared by the runner on the next re-run), LogPath, timestamps, CommitType, Model / SystemPrompt / AgentPath / MaxTurns (nullable overrides), IsStarred, IsMyDay, Notes, ParentTaskId, PlanningSessionId, PlanningSessionToken, PlanningFinalizedAt, CreatedBy. Legacy values `Manual`/`Planning`/`Planned`/`Draft`/`Waiting` were retired; existing rows backfill automatically via the `RetireLegacyTaskStatus` migration.
|
- **TaskEntity** — Id, ListId, Title, Description, Status (`Idle|Queued|Running|WaitingForChildren|WaitingForReview|Done|Failed|Cancelled`), PlanningPhase (`None|Active|Finalized` — parent-only), BlockedByTaskId (nullable FK to predecessor in a chain), ScheduledFor, Result, ReviewFeedback (nullable; reviewer's rejection comment, consumed and cleared by the runner on the next re-run), LogPath, timestamps, CommitType, Model / SystemPrompt / AgentPath / MaxTurns (nullable overrides), IsStarred, IsMyDay, IsManual (reminder only the user can do — automation skips it), Notes, ParentTaskId, PlanningSessionId, PlanningSessionToken, PlanningFinalizedAt, CreatedBy. Legacy values `Manual`/`Planning`/`Planned`/`Draft`/`Waiting` were retired; existing rows backfill automatically via the `RetireLegacyTaskStatus` migration.
|
||||||
- **ListEntity** — Id, Name, WorkingDir, DefaultCommitType, CreatedAt
|
- **ListEntity** — Id, Name, WorkingDir, DefaultCommitType, CreatedAt, IsManual (reminder list — tasks created here default to `IsManual`)
|
||||||
- **ListConfigEntity** — ListId (PK, 1:1 with list), Model, SystemPrompt, AgentPath, MaxTurns (all nullable)
|
- **ListConfigEntity** — ListId (PK, 1:1 with list), Model, SystemPrompt, AgentPath, MaxTurns (all nullable)
|
||||||
- **WorktreeEntity** — TaskId (PK, 1:1 with task), Path, BranchName, BaseCommit, HeadCommit, DiffStat, State (Active|Merged|Discarded|Kept)
|
- **WorktreeEntity** — TaskId (PK, 1:1 with task), Path, BranchName, BaseCommit, HeadCommit, DiffStat, State (Active|Merged|Discarded|Kept)
|
||||||
- **TaskRunEntity** — per-run record (session_id, tokens, turns, result, structured output, exit code, log path)
|
- **TaskRunEntity** — per-run record (session_id, tokens, turns, result, structured output, exit code, log path)
|
||||||
@@ -13,7 +13,9 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio
|
|||||||
- **DailyNoteEntity** — Id, Date (DateOnly), Text, SortOrder, CreatedAt → table `daily_notes`
|
- **DailyNoteEntity** — Id, Date (DateOnly), Text, SortOrder, CreatedAt → table `daily_notes`
|
||||||
- **WeekReportEntity** — Id, StartDate/EndDate (DateOnly), Markdown, GeneratedAt → table `week_reports`, unique index on (start_date, end_date)
|
- **WeekReportEntity** — Id, StartDate/EndDate (DateOnly), Markdown, GeneratedAt → table `week_reports`, unique index on (start_date, end_date)
|
||||||
- **TaskAttachmentEntity** — Id, TaskId (FK to tasks, ON DELETE CASCADE), FileName, ByteSize, CreatedAt → table `task_attachments`
|
- **TaskAttachmentEntity** — Id, TaskId (FK to tasks, ON DELETE CASCADE), FileName, ByteSize, CreatedAt → table `task_attachments`
|
||||||
- **AppSettingsEntity** also carries `ReportExcludedPaths` (string?, JSON array of excluded path prefixes, column `report_excluded_paths`), `StandupWeekday` (int DayOfWeek, default Wednesday, column `standup_weekday`), and `DailyPrepMaxTasks` (int, default 5, column `daily_prep_max_tasks` — hard cap on how many open tasks the daily-prep / "Prime Claude" feature may place in MyDay)
|
- **AppSettingsEntity** also carries `ReportExcludedPaths` (string?, JSON array of excluded path prefixes, column `report_excluded_paths`), `StandupWeekday` (int DayOfWeek, default Wednesday, column `standup_weekday`), and `DailyPrepMaxTasks` (int, default 5, column `daily_prep_max_tasks` — hard cap on how many open tasks the daily-prep / "Prime Claude" feature may place in MyDay) and `ModelPresets` (string?, JSON array of `ModelPreset` rows, column `model_presets`)
|
||||||
|
- **ModelPresets** / **ModelPreset** — per-model run defaults (`Model`, `Effort`, `MaxTurns`): one row per `ModelRegistry.Aliases` entry, supplying the **global** effort and max-turns defaults. `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)` always returns a usable row. Ship defaults: haiku medium/20, sonnet high/30, opus high/40, fable high/25.
|
||||||
|
- **EffortRegistry** — the `--effort` levels (`low|medium|high|xhigh|max`) + `NormalizeLevel` (blank → null = don't pass the flag)
|
||||||
- **SubtaskEntity**, **AppSettingsEntity**, **AgentInfo** — existing helpers / settings / record for scanned agent files
|
- **SubtaskEntity**, **AppSettingsEntity**, **AgentInfo** — existing helpers / settings / record for scanned agent files
|
||||||
|
|
||||||
## Repositories
|
## Repositories
|
||||||
@@ -42,7 +44,7 @@ All repositories use EF Core LINQ queries via `ClaudeDoDbContext`. The atomic `Q
|
|||||||
|
|
||||||
## Schema
|
## Schema
|
||||||
|
|
||||||
Tables: `lists`, `tasks`, `worktrees`, `list_config`, `task_runs`, `subtasks`, `app_settings`, `prime_schedules`, `daily_notes`, `week_reports`, `task_attachments`. Managed by EF Core migrations in the `Migrations/` folder. The `tasks` table holds `status`, `planning_phase` (default `none`), and `blocked_by_task_id` (FK to `tasks.id`, `ON DELETE SET NULL`). Migration `WeeklyReport` added `daily_notes`, `week_reports`, and the two new `app_settings` columns. Migration `DailyPrepMaxTasks` added the `daily_prep_max_tasks` column to `app_settings` (no new tables). Migration `AddTaskAttachments` created the `task_attachments` table. `TaskRepository.DeleteAsync` and `ListRepository.DeleteAsync` also delete the on-disk attachment dir(s) via an optional `AttachmentStore` ctor param (defaults to the production store).
|
Tables: `lists`, `tasks`, `worktrees`, `list_config`, `task_runs`, `subtasks`, `app_settings`, `prime_schedules`, `daily_notes`, `week_reports`, `task_attachments`. Managed by EF Core migrations in the `Migrations/` folder. The `tasks` table holds `status`, `planning_phase` (default `none`), and `blocked_by_task_id` (FK to `tasks.id`, `ON DELETE SET NULL`). Migration `WeeklyReport` added `daily_notes`, `week_reports`, and the two new `app_settings` columns. Migration `DailyPrepMaxTasks` added the `daily_prep_max_tasks` column to `app_settings` (no new tables). Migration `AddTaskAttachments` created the `task_attachments` table. Migration `AddModelPresetsAndManualFlag` added `app_settings.model_presets` plus the `is_manual` flags on `tasks` and `lists`. `TaskRepository.DeleteAsync` and `ListRepository.DeleteAsync` also delete the on-disk attachment dir(s) via an optional `AttachmentStore` ctor param (defaults to the production store).
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ public class AppSettingsEntityConfiguration : IEntityTypeConfiguration<AppSettin
|
|||||||
.HasColumnName("daily_prep_max_tasks").IsRequired().HasDefaultValue(5);
|
.HasColumnName("daily_prep_max_tasks").IsRequired().HasDefaultValue(5);
|
||||||
|
|
||||||
builder.Property(s => s.SessionSkills).HasColumnName("session_skills");
|
builder.Property(s => s.SessionSkills).HasColumnName("session_skills");
|
||||||
|
builder.Property(s => s.ModelPresets).HasColumnName("model_presets");
|
||||||
|
|
||||||
builder.HasData(new AppSettingsEntity { Id = AppSettingsEntity.SingletonId });
|
builder.HasData(new AppSettingsEntity { Id = AppSettingsEntity.SingletonId });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ public class ListEntityConfiguration : IEntityTypeConfiguration<ListEntity>
|
|||||||
builder.Property(l => l.WorkingDir).HasColumnName("working_dir");
|
builder.Property(l => l.WorkingDir).HasColumnName("working_dir");
|
||||||
builder.Property(l => l.DefaultCommitType).HasColumnName("default_commit_type").IsRequired().HasDefaultValue("chore");
|
builder.Property(l => l.DefaultCommitType).HasColumnName("default_commit_type").IsRequired().HasDefaultValue("chore");
|
||||||
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.HasIndex(l => l.SortOrder).HasDatabaseName("idx_lists_sort");
|
builder.HasIndex(l => l.SortOrder).HasDatabaseName("idx_lists_sort");
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
|
|||||||
builder.Property(t => t.MaxTurns).HasColumnName("max_turns");
|
builder.Property(t => t.MaxTurns).HasColumnName("max_turns");
|
||||||
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.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");
|
||||||
|
|||||||
+802
@@ -0,0 +1,802 @@
|
|||||||
|
// <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("20260727114206_AddModelPresetsAndManualFlag")]
|
||||||
|
partial class AddModelPresetsAndManualFlag
|
||||||
|
{
|
||||||
|
/// <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<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(30)
|
||||||
|
.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<string>("ModelPresets")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("model_presets");
|
||||||
|
|
||||||
|
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>("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,
|
||||||
|
DailyPrepMaxTasks = 5,
|
||||||
|
DefaultClaudeInstructions = "",
|
||||||
|
DefaultMaxTurns = 100,
|
||||||
|
DefaultModel = "sonnet",
|
||||||
|
DefaultPermissionMode = "auto",
|
||||||
|
MaxParallelExecutions = 1,
|
||||||
|
StandupWeekday = 3,
|
||||||
|
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>("SessionSkills")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("session_skills");
|
||||||
|
|
||||||
|
b.Property<string>("SystemPrompt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("system_prompt");
|
||||||
|
|
||||||
|
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>("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<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>("Description")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("description");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("FinishedAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("finished_at");
|
||||||
|
|
||||||
|
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<string>("ParentTaskId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("parent_task_id");
|
||||||
|
|
||||||
|
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>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("title");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("BlockedByTaskId")
|
||||||
|
.HasDatabaseName("idx_tasks_blocked_by");
|
||||||
|
|
||||||
|
b.HasIndex("ListId")
|
||||||
|
.HasDatabaseName("idx_tasks_list_id");
|
||||||
|
|
||||||
|
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<string>("ErrorMarkdown")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("error_markdown");
|
||||||
|
|
||||||
|
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>("Prompt")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("prompt");
|
||||||
|
|
||||||
|
b.Property<string>("ResultMarkdown")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("result_markdown");
|
||||||
|
|
||||||
|
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<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>("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.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,57 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ClaudeDo.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddModelPresetsAndManualFlag : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "is_manual",
|
||||||
|
table: "tasks",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "is_manual",
|
||||||
|
table: "lists",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "model_presets",
|
||||||
|
table: "app_settings",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "app_settings",
|
||||||
|
keyColumn: "id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "model_presets",
|
||||||
|
value: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "is_manual",
|
||||||
|
table: "tasks");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "is_manual",
|
||||||
|
table: "lists");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "model_presets",
|
||||||
|
table: "app_settings");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,6 +66,10 @@ namespace ClaudeDo.Data.Migrations
|
|||||||
.HasDefaultValue(1)
|
.HasDefaultValue(1)
|
||||||
.HasColumnName("max_parallel_executions");
|
.HasColumnName("max_parallel_executions");
|
||||||
|
|
||||||
|
b.Property<string>("ModelPresets")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("model_presets");
|
||||||
|
|
||||||
b.Property<string>("RepoImportFolders")
|
b.Property<string>("RepoImportFolders")
|
||||||
.HasColumnType("TEXT")
|
.HasColumnType("TEXT")
|
||||||
.HasColumnName("repo_import_folders");
|
.HasColumnName("repo_import_folders");
|
||||||
@@ -202,6 +206,12 @@ namespace ClaudeDo.Data.Migrations
|
|||||||
.HasDefaultValue("chore")
|
.HasDefaultValue("chore")
|
||||||
.HasColumnName("default_commit_type");
|
.HasColumnName("default_commit_type");
|
||||||
|
|
||||||
|
b.Property<bool>("IsManual")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(false)
|
||||||
|
.HasColumnName("is_manual");
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("TEXT")
|
.HasColumnType("TEXT")
|
||||||
@@ -406,6 +416,12 @@ namespace ClaudeDo.Data.Migrations
|
|||||||
.HasColumnType("TEXT")
|
.HasColumnType("TEXT")
|
||||||
.HasColumnName("finished_at");
|
.HasColumnName("finished_at");
|
||||||
|
|
||||||
|
b.Property<bool>("IsManual")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(false)
|
||||||
|
.HasColumnName("is_manual");
|
||||||
|
|
||||||
b.Property<bool>("IsMyDay")
|
b.Property<bool>("IsMyDay")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("INTEGER")
|
.HasColumnType("INTEGER")
|
||||||
|
|||||||
@@ -30,4 +30,8 @@ public sealed class AppSettingsEntity
|
|||||||
|
|
||||||
// JSON array of session skill names applied by default to new tasks.
|
// JSON array of session skill names applied by default to new tasks.
|
||||||
public string? SessionSkills { get; set; }
|
public string? SessionSkills { get; set; }
|
||||||
|
|
||||||
|
// JSON array of ModelPreset rows (model → effort + max turns). Supplies the global effort and
|
||||||
|
// turn defaults per model; list-/task-level max-turns overrides still win. Null = ship defaults.
|
||||||
|
public string? ModelPresets { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
namespace ClaudeDo.Data.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The reasoning-effort levels the claude CLI accepts for <c>--effort</c>.
|
||||||
|
/// </summary>
|
||||||
|
public static class EffortRegistry
|
||||||
|
{
|
||||||
|
public static readonly IReadOnlyList<string> Levels = new[] { "low", "medium", "high", "xhigh", "max" };
|
||||||
|
|
||||||
|
public const string DefaultLevel = "high";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validate an effort level from external input. Null/blank → null (don't pass --effort).
|
||||||
|
/// Returns the canonical lowercase level; throws on an unknown value.
|
||||||
|
/// </summary>
|
||||||
|
public static string? NormalizeLevel(string? effort)
|
||||||
|
{
|
||||||
|
var e = effort?.Trim();
|
||||||
|
if (string.IsNullOrEmpty(e)) return null;
|
||||||
|
foreach (var level in Levels)
|
||||||
|
if (string.Equals(level, e, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return level;
|
||||||
|
throw new ArgumentException($"Unknown effort '{effort}'. Allowed: {string.Join(", ", Levels)}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,9 @@ public sealed class ListEntity
|
|||||||
public string? WorkingDir { get; set; }
|
public string? WorkingDir { get; set; }
|
||||||
public string DefaultCommitType { get; set; } = CommitTypeRegistry.DefaultType;
|
public string DefaultCommitType { get; set; } = CommitTypeRegistry.DefaultType;
|
||||||
public int SortOrder { get; set; }
|
public int SortOrder { get; set; }
|
||||||
|
// A manual list holds reminders rather than Claude work: new tasks created here start out
|
||||||
|
// manual (see TaskEntity.IsManual).
|
||||||
|
public bool IsManual { get; set; }
|
||||||
|
|
||||||
// Navigation properties
|
// Navigation properties
|
||||||
public ListConfigEntity? Config { get; set; }
|
public ListConfigEntity? Config { get; set; }
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Data.Models;
|
||||||
|
|
||||||
|
/// <summary>Per-model run defaults: which reasoning effort and turn budget a session gets when
|
||||||
|
/// it runs under <paramref name="Model"/> and nothing more specific overrides it.</summary>
|
||||||
|
public sealed record ModelPreset(string Model, string Effort, int MaxTurns);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serialization + lookup for <see cref="AppSettingsEntity.ModelPresets"/> (a JSON array on the
|
||||||
|
/// singleton settings row). One row per <see cref="ModelRegistry.Aliases"/> entry; the row supplies
|
||||||
|
/// the *global* effort and max-turns defaults, which list- and task-level overrides still beat.
|
||||||
|
/// </summary>
|
||||||
|
public static class ModelPresets
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
|
||||||
|
|
||||||
|
/// <summary>Shipping defaults — one row per known model alias.</summary>
|
||||||
|
public static IReadOnlyList<ModelPreset> Defaults { get; } = new[]
|
||||||
|
{
|
||||||
|
new ModelPreset("haiku", "medium", 20),
|
||||||
|
new ModelPreset("sonnet", "high", 30),
|
||||||
|
new ModelPreset("opus", "high", 40),
|
||||||
|
new ModelPreset("fable", "high", 25),
|
||||||
|
};
|
||||||
|
|
||||||
|
public static string SerializeDefaults() => Serialize(Defaults);
|
||||||
|
|
||||||
|
public static string Serialize(IEnumerable<ModelPreset> presets)
|
||||||
|
=> JsonSerializer.Serialize(Normalize(presets), Json);
|
||||||
|
|
||||||
|
/// <summary>Parses the stored JSON, falling back to <see cref="Defaults"/> for anything absent
|
||||||
|
/// or malformed — a bad settings row must never stop a run from starting.</summary>
|
||||||
|
public static IReadOnlyList<ModelPreset> Parse(string? json)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(json)) return Defaults;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var parsed = JsonSerializer.Deserialize<List<ModelPreset>>(json, Json);
|
||||||
|
return parsed is { Count: > 0 } ? Normalize(parsed) : Defaults;
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return Defaults;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The preset for <paramref name="model"/>, or the default row for it. Never null so
|
||||||
|
/// callers don't have to special-case an unconfigured or unknown model.</summary>
|
||||||
|
public static ModelPreset For(IReadOnlyList<ModelPreset> presets, string? model)
|
||||||
|
{
|
||||||
|
var alias = (model ?? ModelRegistry.DefaultAlias).Trim();
|
||||||
|
foreach (var p in presets)
|
||||||
|
if (string.Equals(p.Model, alias, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return p;
|
||||||
|
foreach (var p in Defaults)
|
||||||
|
if (string.Equals(p.Model, alias, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return p;
|
||||||
|
return new ModelPreset(alias, EffortRegistry.DefaultLevel, 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ModelPreset For(string? json, string? model) => For(Parse(json), model);
|
||||||
|
|
||||||
|
// One row per known alias, canonical order, clamped values — so an edited or partial list
|
||||||
|
// still round-trips into something every consumer can rely on.
|
||||||
|
private static IReadOnlyList<ModelPreset> Normalize(IEnumerable<ModelPreset> presets)
|
||||||
|
{
|
||||||
|
var byModel = new Dictionary<string, ModelPreset>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var p in presets)
|
||||||
|
{
|
||||||
|
if (p is null || string.IsNullOrWhiteSpace(p.Model)) continue;
|
||||||
|
byModel[p.Model.Trim()] = p;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = new List<ModelPreset>();
|
||||||
|
foreach (var fallback in Defaults)
|
||||||
|
{
|
||||||
|
var source = byModel.TryGetValue(fallback.Model, out var found) ? found : fallback;
|
||||||
|
var effort = TryNormalizeEffort(source.Effort) ?? fallback.Effort;
|
||||||
|
var turns = source.MaxTurns is >= 1 and <= 200 ? source.MaxTurns : fallback.MaxTurns;
|
||||||
|
result.Add(new ModelPreset(fallback.Model, effort, turns));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? TryNormalizeEffort(string? effort)
|
||||||
|
{
|
||||||
|
try { return EffortRegistry.NormalizeLevel(effort); }
|
||||||
|
catch (ArgumentException) { return null; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ namespace ClaudeDo.Data.Models;
|
|||||||
|
|
||||||
public static class ModelRegistry
|
public static class ModelRegistry
|
||||||
{
|
{
|
||||||
public static readonly IReadOnlyList<string> Aliases = new[] { "sonnet", "opus", "haiku" };
|
public static readonly IReadOnlyList<string> Aliases = new[] { "sonnet", "opus", "haiku", "fable" };
|
||||||
|
|
||||||
/// <summary>Model aliases ordered cheapest → most capable. Single source for prompt cost guidance.</summary>
|
/// <summary>Model aliases ordered cheapest → most capable. Single source for prompt cost guidance.</summary>
|
||||||
public static readonly IReadOnlyList<string> ByCostAscending = new[] { "haiku", "sonnet", "opus" };
|
public static readonly IReadOnlyList<string> ByCostAscending = new[] { "haiku", "sonnet", "opus" };
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ public sealed class TaskEntity
|
|||||||
public int? MaxTurns { get; set; }
|
public int? MaxTurns { get; set; }
|
||||||
public bool IsStarred { get; set; }
|
public bool IsStarred { get; set; }
|
||||||
public bool IsMyDay { get; set; }
|
public bool IsMyDay { get; set; }
|
||||||
|
// Manual = a reminder only the user can do. Automation skips it (queue picker, daily prep,
|
||||||
|
// 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.
|
||||||
|
public bool IsManual { 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; }
|
||||||
|
|||||||
@@ -229,22 +229,57 @@ public static class PromptFiles
|
|||||||
""";
|
""";
|
||||||
|
|
||||||
private const string MergeHelperDefault = """
|
private const string MergeHelperDefault = """
|
||||||
You are the ClaudeDo Merge Helper, running as an interactive session with the user watching. Ask them questions whenever you are unsure — that is the point of this session.
|
You are the ClaudeDo list handler, running as an interactive session with the user watching. Ask them questions whenever you are unsure — that is the point of this session.
|
||||||
|
|
||||||
Your job: take the tasks listed in the brief and drive each one to a merged, Done state, then print a summary of everything that changed. You act through the mcp__claudedo__* tools. Read the brief file first (the kickoff message gives its path); it lists each task's id, title, status, list and repo.
|
Your job: take the tasks listed in the brief and drive the whole set to merged, Done work — reading them first, removing duplicates, sharpening what stays, running it, then reviewing and merging each result. You act through the mcp__claudedo__* tools. Read the brief file first (the kickoff message gives its path); it names the list, its repo, and every task's id, title and status. All tasks belong to that one list and one repo.
|
||||||
|
|
||||||
Handle the tasks in the order listed, one at a time. For each task, act on its current status:
|
Work the five phases in order. Do not start a phase before the previous one is finished.
|
||||||
- Idle or Queued: run it with run_task_now, then poll get_task until it leaves Running (it lands in WaitingForReview on success, or Failed).
|
|
||||||
- Running or WaitingForChildren: poll get_task until it surfaces for review.
|
|
||||||
- Failed: this usually needs human judgement — ask the user whether to reset_failed_task and re-run, or skip it.
|
|
||||||
- WaitingForReview: review, then merge it (below).
|
|
||||||
|
|
||||||
Reviewing and merging a WaitingForReview task:
|
## Phase 0 — Read everything
|
||||||
1. Inspect the change with get_task_diff (stat first, then the full diff if it is non-trivial) and sanity-check it against the task's title/description.
|
Call batch_get_tasks with every id from the brief and read each task's title, description, status and parent/child links. Do not act on any single task before you have read them all — Phase 1 needs the whole set in view.
|
||||||
|
|
||||||
|
## Phase 1 — Dedupe
|
||||||
|
Compare the tasks pairwise for overlap: same goal stated twice, one task fully contained in another, two tasks that would edit the same thing for the same reason.
|
||||||
|
|
||||||
|
Print a table of the candidate pairs with, for each, the reason it looks like a duplicate. Then ask the user about EACH pair, one at a time:
|
||||||
|
- merge → fold whatever the loser says that the survivor does not into the survivor via update_task, then update_task_status(loserId, "Cancelled"). Cancelled keeps the task visible and resettable; never use delete_task for this.
|
||||||
|
- keep both → note why and move on.
|
||||||
|
|
||||||
|
Cancel nothing without an explicit answer. If there are no duplicates, say so and go on.
|
||||||
|
|
||||||
|
## Phase 2 — Enhance for execution
|
||||||
|
Each surviving task is about to be run by an autonomous agent with no further input. Sharpen it so that run can succeed. For each task, rewrite title and description to carry:
|
||||||
|
- concrete acceptance criteria — what must be true when it is done,
|
||||||
|
- the files and areas actually involved, found with Read/Grep/Glob in the repo. Do not guess paths; look them up.
|
||||||
|
- what is explicitly out of scope.
|
||||||
|
|
||||||
|
Write it back with update_task (title, description and commitType are the settable fields).
|
||||||
|
|
||||||
|
Rules: do not change what the user asked for, and do not invent requirements. You are making the existing intent precise, not adding to it. If a task is too vague to sharpen without guessing, ASK instead of guessing. Report a short before/after per task.
|
||||||
|
|
||||||
|
## Phase 3 — Run
|
||||||
|
Do NOT use run_task_now for a batch — there is a single override slot and the second call fails with "override slot busy".
|
||||||
|
|
||||||
|
Read get_app_settings and tell the user how many parallel execution slots are configured (maxParallelExecutions). If it is 1, say plainly that the tasks will execute one after another and that the value is changeable in ClaudeDo's settings.
|
||||||
|
|
||||||
|
Then, for each surviving task:
|
||||||
|
- Idle or Failed → update_task_status(id, "Queued"). For a Failed task ask first whether to reset_failed_task and re-queue it, or skip it.
|
||||||
|
- Queued → leave it; it is already waiting for a slot.
|
||||||
|
- Running or WaitingForChildren → leave it; only poll.
|
||||||
|
- WaitingForReview → leave it; it goes straight to Phase 4.
|
||||||
|
|
||||||
|
Poll get_task until every task has left Queued and Running — WaitingForReview on success, Failed on error. Report progress as tasks land; do not poll silently for minutes.
|
||||||
|
|
||||||
|
## Phase 4 — Review and merge
|
||||||
|
One task at a time, in the order the brief lists them.
|
||||||
|
|
||||||
|
1. Inspect the change with get_task_diff (stat first, then the full diff if it is non-trivial) and sanity-check it against the task's title and description.
|
||||||
2. If the change looks wrong, incomplete, or risky, STOP and ask the user before merging — offer reject_rerun (with feedback) or skip.
|
2. If the change looks wrong, incomplete, or risky, STOP and ask the user before merging — offer reject_rerun (with feedback) or skip.
|
||||||
3. Otherwise merge it with review_task(taskId, decision="approve", leaveConflictsInTree=true).
|
3. Otherwise merge with review_task(taskId, decision="approve", leaveConflictsInTree=true).
|
||||||
- Clean merge → the task is Done; move on.
|
- Clean merge → the task is Done; move on.
|
||||||
- Conflict (markers left in the working tree, repoPath returned) → resolve it (below).
|
- Conflict (markers left in the working tree, repoPath returned) → resolve it.
|
||||||
|
|
||||||
|
Every branch in this run forked from the same base, so conflicts between them are the NORMAL case, not a failure. Resolve them and keep going; do not abandon the run because a merge conflicted.
|
||||||
|
|
||||||
Resolving a conflict:
|
Resolving a conflict:
|
||||||
- Open each conflicted file under repoPath (Read/Edit) and resolve the <<<<<<< ======= >>>>>>> markers, guided by BOTH sides' intent. Then call continue_merge(taskId). If markers remain it tells you — fix and call again. Use abort_merge(taskId) to cancel a merge you cannot safely resolve.
|
- Open each conflicted file under repoPath (Read/Edit) and resolve the <<<<<<< ======= >>>>>>> markers, guided by BOTH sides' intent. Then call continue_merge(taskId). If markers remain it tells you — fix and call again. Use abort_merge(taskId) to cancel a merge you cannot safely resolve.
|
||||||
@@ -252,22 +287,24 @@ public static class PromptFiles
|
|||||||
- If a resolution is non-obvious, ambiguous, or might drop someone's work, ASK THE USER before continuing.
|
- If a resolution is non-obvious, ambiguous, or might drop someone's work, ASK THE USER before continuing.
|
||||||
- Prefer the MCP tools whenever they apply. Only if the MCP tools cannot reach an in-progress merge may you finish it by hand: resolve the markers, then `git add -- <the resolved paths>` and `git commit` — NEVER `git add -A` or a bare commit, because the checkout is shared with other sessions.
|
- Prefer the MCP tools whenever they apply. Only if the MCP tools cannot reach an in-progress merge may you finish it by hand: resolve the markers, then `git add -- <the resolved paths>` and `git commit` — NEVER `git add -A` or a bare commit, because the checkout is shared with other sessions.
|
||||||
|
|
||||||
Rules:
|
Rules for the whole session:
|
||||||
- Never use raw `git merge`, `git reset`, or `git checkout` to force a merge. Drive merges through the MCP tools; hand-resolution is only for markers the tools left and cannot finish.
|
- Never use raw `git merge`, `git reset`, or `git checkout` to force a merge. Drive merges through the MCP tools; hand-resolution is only for markers the tools left and cannot finish.
|
||||||
- Ask the user for anything ambiguous, risky, or destructive.
|
- Ask the user for anything ambiguous, risky, or destructive.
|
||||||
|
|
||||||
When every task is handled, print a SUMMARY:
|
## Phase 5 — Summary
|
||||||
- one line per task: title — final status — merge commit (if any) — conflicts resolved (if any)
|
Print one line per task from the original brief:
|
||||||
- anything you skipped or left for the user, and why
|
title — dedupe action (kept / merged into X / cancelled as duplicate of X) — enhanced (yes/no) — final status — merge commit (if any) — conflicts resolved (if any).
|
||||||
- suggested follow-ups, if any.
|
|
||||||
|
Then list anything you skipped or left for the user and why, and any follow-ups worth turning into new tasks.
|
||||||
""";
|
""";
|
||||||
|
|
||||||
private const string MergeHelperInitialDefault = """
|
private const string MergeHelperInitialDefault = """
|
||||||
# Merge Helper brief
|
# List handler brief
|
||||||
|
|
||||||
Scope: {scope}
|
Scope: {scope}
|
||||||
|
Repo: {repo}
|
||||||
|
|
||||||
Handle the following tasks, in order. For each, drive it to a merged/Done state per your instructions, asking me when unsure.
|
Handle the following tasks. Work Phases 0–5 as your instructions describe, asking me whenever you are unsure.
|
||||||
|
|
||||||
{tasks}
|
{tasks}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,9 @@
|
|||||||
"defaultInstructionsPlaceholder": "Basis-Anweisungen, die auf jede Aufgabe angewendet werden",
|
"defaultInstructionsPlaceholder": "Basis-Anweisungen, die auf jede Aufgabe angewendet werden",
|
||||||
"model": "Modell",
|
"model": "Modell",
|
||||||
"maxTurns": "Max. Durchläufe",
|
"maxTurns": "Max. Durchläufe",
|
||||||
|
"effort": "Effort",
|
||||||
|
"modelPresets": "Vorgaben pro Modell",
|
||||||
|
"modelPresetsHint": "Effort und Durchlauf-Budget, mit denen eine Sitzung unter diesem Modell läuft. Overrides auf Listen- und Aufgabenebene gewinnen weiterhin.",
|
||||||
"permission": "Berechtigung",
|
"permission": "Berechtigung",
|
||||||
"maxParallelExecutions": "Max. parallele Ausführungen",
|
"maxParallelExecutions": "Max. parallele Ausführungen",
|
||||||
"maxParallelExecutionsHint": "Wie viele Aufgaben aus der Warteschlange der Worker gleichzeitig ausführt.",
|
"maxParallelExecutionsHint": "Wie viele Aufgaben aus der Warteschlange der Worker gleichzeitig ausführt.",
|
||||||
@@ -141,6 +144,10 @@
|
|||||||
"ctxAddToMyDay": "Zu Mein Tag hinzufügen",
|
"ctxAddToMyDay": "Zu Mein Tag hinzufügen",
|
||||||
"badgeDraft": "ENTWURF",
|
"badgeDraft": "ENTWURF",
|
||||||
"badgePlanned": "GEPLANT",
|
"badgePlanned": "GEPLANT",
|
||||||
|
"badgeManual": "MANUELL",
|
||||||
|
"manualTip": "Manuelle Erinnerung — Claude nimmt sie nie auf",
|
||||||
|
"ctxMarkManual": "Als manuell markieren",
|
||||||
|
"ctxMarkClaudeTask": "Als Claude-Aufgabe markieren",
|
||||||
"approve": "Genehmigen",
|
"approve": "Genehmigen",
|
||||||
"approveTip": "Genehmigen — als Erledigt markieren",
|
"approveTip": "Genehmigen — als Erledigt markieren",
|
||||||
"reject": "Ablehnen",
|
"reject": "Ablehnen",
|
||||||
@@ -152,6 +159,7 @@
|
|||||||
"removeFromQueueTip": "Aus Warteschlange entfernen",
|
"removeFromQueueTip": "Aus Warteschlange entfernen",
|
||||||
"toggleSubtasksTip": "Unteraufgaben ein-/ausklappen",
|
"toggleSubtasksTip": "Unteraufgaben ein-/ausklappen",
|
||||||
"agentSuggestedTip": "Vom Agenten vorgeschlagen",
|
"agentSuggestedTip": "Vom Agenten vorgeschlagen",
|
||||||
|
"interactiveChipTip": "Interaktive Sitzung offen — klicken, um im Mission Control dorthin zu springen",
|
||||||
"scheduleTitle": "Aufgabe planen",
|
"scheduleTitle": "Aufgabe planen",
|
||||||
"scheduleWhen": "WANN",
|
"scheduleWhen": "WANN",
|
||||||
"scheduleConfirm": "Planen",
|
"scheduleConfirm": "Planen",
|
||||||
@@ -160,7 +168,8 @@
|
|||||||
"feedbackLabel": "FEEDBACK FÜR DEN AGENTEN",
|
"feedbackLabel": "FEEDBACK FÜR DEN AGENTEN",
|
||||||
"feedbackPlaceholder": "Was soll der Agent korrigieren?",
|
"feedbackPlaceholder": "Was soll der Agent korrigieren?",
|
||||||
"rerun": "Erneut ausführen",
|
"rerun": "Erneut ausführen",
|
||||||
"refineTip": "Aufgabe mit Claude verfeinern"
|
"refineTip": "Aufgabe mit Claude verfeinern",
|
||||||
|
"refiningTip": "Claude verfeinert diese Aufgabe…"
|
||||||
},
|
},
|
||||||
"lists": {
|
"lists": {
|
||||||
"heading": "Listen",
|
"heading": "Listen",
|
||||||
@@ -175,8 +184,7 @@
|
|||||||
"contextOpenTerminal": "Im Terminal öffnen",
|
"contextOpenTerminal": "Im Terminal öffnen",
|
||||||
"contextLetClaude": "Claude machen lassen",
|
"contextLetClaude": "Claude machen lassen",
|
||||||
"newList": "Neue Liste",
|
"newList": "Neue Liste",
|
||||||
"addReposTip": "Repos als Listen hinzufügen",
|
"addReposTip": "Repos als Listen hinzufügen"
|
||||||
"letClaudeAllTip": "Claude machen lassen (alle Listen)"
|
|
||||||
},
|
},
|
||||||
"details": {
|
"details": {
|
||||||
"pickUpInTerminalTip": "Diese Sitzung im Terminal fortsetzen",
|
"pickUpInTerminalTip": "Diese Sitzung im Terminal fortsetzen",
|
||||||
@@ -268,6 +276,7 @@
|
|||||||
"overviewMode": "Übersicht",
|
"overviewMode": "Übersicht",
|
||||||
"closeSession": "Sitzung schließen",
|
"closeSession": "Sitzung schließen",
|
||||||
"conptyLaunchFailed": "ConPTY-Sitzung konnte nicht geöffnet werden: {0}",
|
"conptyLaunchFailed": "ConPTY-Sitzung konnte nicht geöffnet werden: {0}",
|
||||||
|
"conptyStarting": "Sitzung wird gestartet…",
|
||||||
"mergeHelperTitle": "Merge-Helfer",
|
"mergeHelperTitle": "Merge-Helfer",
|
||||||
"submitForReviewFailed": "Einreichen zum Review fehlgeschlagen: {0}",
|
"submitForReviewFailed": "Einreichen zum Review fehlgeschlagen: {0}",
|
||||||
"submitForReview": "Zum Review einreichen",
|
"submitForReview": "Zum Review einreichen",
|
||||||
@@ -313,6 +322,8 @@
|
|||||||
"workingDirectoryPlaceholder": "(keines)",
|
"workingDirectoryPlaceholder": "(keines)",
|
||||||
"browse": "Durchsuchen...",
|
"browse": "Durchsuchen...",
|
||||||
"defaultCommitType": "Standard-Commit-Typ",
|
"defaultCommitType": "Standard-Commit-Typ",
|
||||||
|
"manualList": "Manuelle Liste (Erinnerungen)",
|
||||||
|
"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.",
|
||||||
"sectionAgent": "AGENT",
|
"sectionAgent": "AGENT",
|
||||||
"resetAgentSettings": "Agent-Einstellungen zurücksetzen"
|
"resetAgentSettings": "Agent-Einstellungen zurücksetzen"
|
||||||
},
|
},
|
||||||
@@ -371,7 +382,6 @@
|
|||||||
"mergeHelper": {
|
"mergeHelper": {
|
||||||
"title": "CLAUDE MACHEN LASSEN",
|
"title": "CLAUDE MACHEN LASSEN",
|
||||||
"windowTitle": "Claude machen lassen",
|
"windowTitle": "Claude machen lassen",
|
||||||
"scopeAll": "Alle Listen",
|
|
||||||
"scopeList": "Liste: {0}",
|
"scopeList": "Liste: {0}",
|
||||||
"confirm": "Claude machen lassen",
|
"confirm": "Claude machen lassen",
|
||||||
"cancel": "Abbrechen",
|
"cancel": "Abbrechen",
|
||||||
@@ -379,8 +389,7 @@
|
|||||||
"selectNone": "Keine auswählen",
|
"selectNone": "Keine auswählen",
|
||||||
"empty": "Keine Aufgaben zu übergeben — alles erledigt oder abgebrochen.",
|
"empty": "Keine Aufgaben zu übergeben — alles erledigt oder abgebrochen.",
|
||||||
"columnTask": "AUFGABE",
|
"columnTask": "AUFGABE",
|
||||||
"columnStatus": "STATUS",
|
"columnStatus": "STATUS"
|
||||||
"columnList": "LISTE"
|
|
||||||
},
|
},
|
||||||
"unfinishedPlanning": {
|
"unfinishedPlanning": {
|
||||||
"title": "UNVOLLENDETE PLANUNGSSITZUNG",
|
"title": "UNVOLLENDETE PLANUNGSSITZUNG",
|
||||||
@@ -524,7 +533,7 @@
|
|||||||
"connection": { "online": "Online", "connecting": "Verbinden…", "offline": "Offline" },
|
"connection": { "online": "Online", "connecting": "Verbinden…", "offline": "Offline" },
|
||||||
"shell": { "restartingWorker": "Worker wird neu gestartet…" },
|
"shell": { "restartingWorker": "Worker wird neu gestartet…" },
|
||||||
"agentStatus": { "idle": "Leerlauf", "queued": "In Warteschlange", "running": "Läuft", "review": "Prüfung", "children": "Wartet auf Teilaufgaben", "done": "Fertig", "failed": "Fehlgeschlagen", "cancelled": "Abgebrochen" },
|
"agentStatus": { "idle": "Leerlauf", "queued": "In Warteschlange", "running": "Läuft", "review": "Prüfung", "children": "Wartet auf Teilaufgaben", "done": "Fertig", "failed": "Fehlgeschlagen", "cancelled": "Abgebrochen" },
|
||||||
"taskStatus": { "idle": "Leerlauf", "queued": "In Warteschlange", "running": "Läuft", "waitingForReview": "Wartet auf Prüfung", "waitingForChildren": "Wartet auf Teilaufgaben", "done": "Fertig", "failed": "Fehlgeschlagen", "cancelled": "Abgebrochen", "parked": "Geparkt" },
|
"taskStatus": { "idle": "Leerlauf", "queued": "In Warteschlange", "running": "Läuft", "waitingForReview": "Wartet auf Prüfung", "waitingForChildren": "Wartet auf Teilaufgaben", "done": "Fertig", "failed": "Fehlgeschlagen", "cancelled": "Abgebrochen", "parked": "Geparkt", "interactive": "Interaktiv" },
|
||||||
"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" },
|
||||||
"tasksIsland": { "completedHeader": "ABGESCHLOSSEN", "completedHeaderCount": "ABGESCHLOSSEN · {0}", "planningOpenFailed": "Planungssitzung konnte nicht geöffnet werden: {0}", "planningResumeFailed": "Planungssitzung konnte nicht fortgesetzt werden: {0}", "pickUpInTerminalFailed": "Im Terminal fortsetzen fehlgeschlagen: {0}", "approveFailed": "Genehmigen & Mergen fehlgeschlagen: {0}" },
|
"tasksIsland": { "completedHeader": "ABGESCHLOSSEN", "completedHeaderCount": "ABGESCHLOSSEN · {0}", "planningOpenFailed": "Planungssitzung konnte nicht geöffnet werden: {0}", "planningResumeFailed": "Planungssitzung konnte nicht fortgesetzt werden: {0}", "pickUpInTerminalFailed": "Im Terminal fortsetzen fehlgeschlagen: {0}", "approveFailed": "Genehmigen & Mergen fehlgeschlagen: {0}" },
|
||||||
|
|||||||
@@ -15,6 +15,9 @@
|
|||||||
"defaultInstructionsPlaceholder": "Baseline instructions applied to every task",
|
"defaultInstructionsPlaceholder": "Baseline instructions applied to every task",
|
||||||
"model": "Model",
|
"model": "Model",
|
||||||
"maxTurns": "Max turns",
|
"maxTurns": "Max turns",
|
||||||
|
"effort": "Effort",
|
||||||
|
"modelPresets": "Per-model defaults",
|
||||||
|
"modelPresetsHint": "Reasoning effort and turn budget used when a session runs under that model. List- and task-level max-turn overrides still win.",
|
||||||
"permission": "Permission",
|
"permission": "Permission",
|
||||||
"maxParallelExecutions": "Max parallel executions",
|
"maxParallelExecutions": "Max parallel executions",
|
||||||
"maxParallelExecutionsHint": "How many queued tasks the worker runs at once.",
|
"maxParallelExecutionsHint": "How many queued tasks the worker runs at once.",
|
||||||
@@ -141,6 +144,10 @@
|
|||||||
"ctxAddToMyDay": "Add to My Day",
|
"ctxAddToMyDay": "Add to My Day",
|
||||||
"badgeDraft": "DRAFT",
|
"badgeDraft": "DRAFT",
|
||||||
"badgePlanned": "PLANNED",
|
"badgePlanned": "PLANNED",
|
||||||
|
"badgeManual": "MANUAL",
|
||||||
|
"manualTip": "Manual reminder — Claude never picks this up",
|
||||||
|
"ctxMarkManual": "Mark as manual",
|
||||||
|
"ctxMarkClaudeTask": "Mark as Claude task",
|
||||||
"approve": "Approve",
|
"approve": "Approve",
|
||||||
"approveTip": "Approve — mark Done",
|
"approveTip": "Approve — mark Done",
|
||||||
"reject": "Reject",
|
"reject": "Reject",
|
||||||
@@ -152,6 +159,7 @@
|
|||||||
"removeFromQueueTip": "Remove from queue",
|
"removeFromQueueTip": "Remove from queue",
|
||||||
"toggleSubtasksTip": "Expand / collapse subtasks",
|
"toggleSubtasksTip": "Expand / collapse subtasks",
|
||||||
"agentSuggestedTip": "Suggested by the agent",
|
"agentSuggestedTip": "Suggested by the agent",
|
||||||
|
"interactiveChipTip": "Interactive session open — click to jump to it in Mission Control",
|
||||||
"scheduleTitle": "Schedule task",
|
"scheduleTitle": "Schedule task",
|
||||||
"scheduleWhen": "WHEN",
|
"scheduleWhen": "WHEN",
|
||||||
"scheduleConfirm": "Schedule",
|
"scheduleConfirm": "Schedule",
|
||||||
@@ -160,7 +168,8 @@
|
|||||||
"feedbackLabel": "FEEDBACK FOR THE AGENT",
|
"feedbackLabel": "FEEDBACK FOR THE AGENT",
|
||||||
"feedbackPlaceholder": "What should the agent fix?",
|
"feedbackPlaceholder": "What should the agent fix?",
|
||||||
"rerun": "Re-run",
|
"rerun": "Re-run",
|
||||||
"refineTip": "Refine this task with Claude"
|
"refineTip": "Refine this task with Claude",
|
||||||
|
"refiningTip": "Claude is refining this task…"
|
||||||
},
|
},
|
||||||
"lists": {
|
"lists": {
|
||||||
"heading": "Lists",
|
"heading": "Lists",
|
||||||
@@ -175,8 +184,7 @@
|
|||||||
"contextOpenTerminal": "Open in Terminal",
|
"contextOpenTerminal": "Open in Terminal",
|
||||||
"contextLetClaude": "Let Claude handle it",
|
"contextLetClaude": "Let Claude handle it",
|
||||||
"newList": "New list",
|
"newList": "New list",
|
||||||
"addReposTip": "Add repos as lists",
|
"addReposTip": "Add repos as lists"
|
||||||
"letClaudeAllTip": "Let Claude handle it (all lists)"
|
|
||||||
},
|
},
|
||||||
"details": {
|
"details": {
|
||||||
"pickUpInTerminalTip": "Pick up this session in a terminal",
|
"pickUpInTerminalTip": "Pick up this session in a terminal",
|
||||||
@@ -268,6 +276,7 @@
|
|||||||
"overviewMode": "Overview",
|
"overviewMode": "Overview",
|
||||||
"closeSession": "Close session",
|
"closeSession": "Close session",
|
||||||
"conptyLaunchFailed": "Couldn't open ConPTY session: {0}",
|
"conptyLaunchFailed": "Couldn't open ConPTY session: {0}",
|
||||||
|
"conptyStarting": "Starting session…",
|
||||||
"mergeHelperTitle": "Merge Helper",
|
"mergeHelperTitle": "Merge Helper",
|
||||||
"submitForReviewFailed": "Couldn't submit for review: {0}",
|
"submitForReviewFailed": "Couldn't submit for review: {0}",
|
||||||
"submitForReview": "Submit for review",
|
"submitForReview": "Submit for review",
|
||||||
@@ -313,6 +322,8 @@
|
|||||||
"workingDirectoryPlaceholder": "(none)",
|
"workingDirectoryPlaceholder": "(none)",
|
||||||
"browse": "Browse...",
|
"browse": "Browse...",
|
||||||
"defaultCommitType": "Default commit type",
|
"defaultCommitType": "Default commit type",
|
||||||
|
"manualList": "Manual list (reminders)",
|
||||||
|
"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.",
|
||||||
"sectionAgent": "AGENT",
|
"sectionAgent": "AGENT",
|
||||||
"resetAgentSettings": "Reset agent settings"
|
"resetAgentSettings": "Reset agent settings"
|
||||||
},
|
},
|
||||||
@@ -371,7 +382,6 @@
|
|||||||
"mergeHelper": {
|
"mergeHelper": {
|
||||||
"title": "LET CLAUDE HANDLE IT",
|
"title": "LET CLAUDE HANDLE IT",
|
||||||
"windowTitle": "Let Claude handle it",
|
"windowTitle": "Let Claude handle it",
|
||||||
"scopeAll": "All lists",
|
|
||||||
"scopeList": "List: {0}",
|
"scopeList": "List: {0}",
|
||||||
"confirm": "Let Claude handle it",
|
"confirm": "Let Claude handle it",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
@@ -379,8 +389,7 @@
|
|||||||
"selectNone": "Select none",
|
"selectNone": "Select none",
|
||||||
"empty": "No tasks to hand over — everything is done or cancelled.",
|
"empty": "No tasks to hand over — everything is done or cancelled.",
|
||||||
"columnTask": "TASK",
|
"columnTask": "TASK",
|
||||||
"columnStatus": "STATUS",
|
"columnStatus": "STATUS"
|
||||||
"columnList": "LIST"
|
|
||||||
},
|
},
|
||||||
"unfinishedPlanning": {
|
"unfinishedPlanning": {
|
||||||
"title": "UNFINISHED PLANNING SESSION",
|
"title": "UNFINISHED PLANNING SESSION",
|
||||||
@@ -524,7 +533,7 @@
|
|||||||
"connection": { "online": "Online", "connecting": "Connecting…", "offline": "Offline" },
|
"connection": { "online": "Online", "connecting": "Connecting…", "offline": "Offline" },
|
||||||
"shell": { "restartingWorker": "Restarting worker…" },
|
"shell": { "restartingWorker": "Restarting worker…" },
|
||||||
"agentStatus": { "idle": "Idle", "queued": "Queued", "running": "Running", "review": "Review", "children": "Waiting for Subtasks", "done": "Done", "failed": "Failed", "cancelled": "Cancelled" },
|
"agentStatus": { "idle": "Idle", "queued": "Queued", "running": "Running", "review": "Review", "children": "Waiting for Subtasks", "done": "Done", "failed": "Failed", "cancelled": "Cancelled" },
|
||||||
"taskStatus": { "idle": "Idle", "queued": "Queued", "running": "Running", "waitingForReview": "Waiting for Review", "waitingForChildren": "Waiting for Subtasks", "done": "Done", "failed": "Failed", "cancelled": "Cancelled", "parked": "Parked" },
|
"taskStatus": { "idle": "Idle", "queued": "Queued", "running": "Running", "waitingForReview": "Waiting for Review", "waitingForChildren": "Waiting for Subtasks", "done": "Done", "failed": "Failed", "cancelled": "Cancelled", "parked": "Parked", "interactive": "Interactive" },
|
||||||
"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" },
|
||||||
"tasksIsland": { "completedHeader": "COMPLETED", "completedHeaderCount": "COMPLETED · {0}", "planningOpenFailed": "Couldn't open planning session: {0}", "planningResumeFailed": "Couldn't resume planning session: {0}", "pickUpInTerminalFailed": "Pick up in terminal failed: {0}", "approveFailed": "Approve & merge failed: {0}" },
|
"tasksIsland": { "completedHeader": "COMPLETED", "completedHeaderCount": "COMPLETED · {0}", "planningOpenFailed": "Couldn't open planning session: {0}", "planningResumeFailed": "Couldn't resume planning session: {0}", "pickUpInTerminalFailed": "Pick up in terminal failed: {0}", "approveFailed": "Approve & merge failed: {0}" },
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ ViewModels/
|
|||||||
Islands/ — ListsIsland, TasksIsland, DetailsIsland, TaskRow, ListNavItem,
|
Islands/ — ListsIsland, TasksIsland, DetailsIsland, TaskRow, ListNavItem,
|
||||||
NotesEditor, MergePreviewPresenter
|
NotesEditor, MergePreviewPresenter
|
||||||
Agent/ — AgentConfigEditorViewModel (scope-parameterized: List | Task)
|
Agent/ — AgentConfigEditorViewModel (scope-parameterized: List | Task)
|
||||||
Modals/ — About, DiffViewer (+ DiffModels), ListSettings, Merge, RepoImport,
|
Modals/ — About, DiffViewer (+ DiffModels), ListSettings, Merge, MergeHelperSelection,
|
||||||
Settings (+ Settings/ tab VMs), UnfinishedPlanning, WeeklyReport,
|
RepoImport, Settings (+ Settings/ tab VMs), UnfinishedPlanning, WeeklyReport,
|
||||||
WorkerConnection, WorktreesOverview, UnifiedDiffParser
|
WorkerConnection, WorktreesOverview, UnifiedDiffParser
|
||||||
Conflicts/ — ConflictResolverViewModel + ConflictModels (MergeFile/MergeFileSegment/MergeConflictBlock)
|
Conflicts/ — ConflictResolverViewModel + ConflictModels (MergeFile/MergeFileSegment/MergeConflictBlock)
|
||||||
Views/ — mirrors the VM layout; Islands/Detail/ holds TaskHeaderBar,
|
Views/ — mirrors the VM layout; Islands/Detail/ holds TaskHeaderBar,
|
||||||
@@ -35,11 +35,11 @@ Design/ — Tokens.axaml (design tokens; merged before styles) + IslandStyle
|
|||||||
|
|
||||||
- **IslandsShellViewModel** — root coordinator; owns the three island VMs and the `WorkerClient`, wires cross-island events (selection, notes/prep mode, conflict resolution), owns connection state, the update banner, the inline worker-log strip (clickable → Log Visualizer overlay via `OpenLogVisualizerCommand`; `FlashFooterError` surfaces UI-action failures + the worker's Serilog Warn/Error there), responsive-layout flags (`ShowLists`/`ShowDetails` by window width), `PrimeStatus` flash, and the modal openers (About, RepoImport, WeeklyReport, WorktreesOverview, WorkerConnection help, LogVisualizer) plus `RestartWorkerAsync`/`CheckForUpdatesAsync`. Hosts `UpdateCheckService`.
|
- **IslandsShellViewModel** — root coordinator; owns the three island VMs and the `WorkerClient`, wires cross-island events (selection, notes/prep mode, conflict resolution), owns connection state, the update banner, the inline worker-log strip (clickable → Log Visualizer overlay via `OpenLogVisualizerCommand`; `FlashFooterError` surfaces UI-action failures + the worker's Serilog Warn/Error there), responsive-layout flags (`ShowLists`/`ShowDetails` by window width), `PrimeStatus` flash, and the modal openers (About, RepoImport, WeeklyReport, WorktreesOverview, WorkerConnection help, LogVisualizer) plus `RestartWorkerAsync`/`CheckForUpdatesAsync`. Hosts `UpdateCheckService`.
|
||||||
- **ListsIslandViewModel** — smart lists (My Day, Important, Planned, virtual queued/running/review), user lists, selection, list CRUD, drag-reorder, badge counts, opens list settings / repo import / worktrees overview, `OpenInExplorer`/`OpenInTerminal`.
|
- **ListsIslandViewModel** — smart lists (My Day, Important, Planned, virtual queued/running/review), user lists, selection, list CRUD, drag-reorder, badge counts, opens list settings / repo import / worktrees overview, `OpenInExplorer`/`OpenInTerminal`.
|
||||||
- **TasksIslandViewModel** — open/overdue/completed groups for the selected list with hierarchy-aware regrouping; task CRUD, drag-reorder, toggle done/star, schedule, enqueue/dequeue, cancel; review actions (approve, reject-rerun, reject-park, cancel); planning session lifecycle (open/resume/discard/finalize, `QueuePlanningSubtasksAsync`); `RefineTask`, `OpenConPtySessionRequested` (embedded ConPTY terminal), `PickUpInTerminalAsync`; MyDay extras (`IsMyDayList`, `ClearDayCommand`, `ShowPrepLogCommand`) and the pinned Notes pseudo-row (`ShowNotesRow`, `OpenNotesCommand`). Raises `NotesRequested`/`PrepRequested` events consumed by the shell.
|
- **TasksIslandViewModel** — open/overdue/completed groups for the selected list with hierarchy-aware regrouping; task CRUD, drag-reorder, toggle done/star, schedule, enqueue/dequeue, cancel; review actions (approve, reject-rerun, reject-park, cancel); planning session lifecycle (open/resume/discard/finalize, `QueuePlanningSubtasksAsync`); `RefineTask`, `OpenConPtySessionRequested` (embedded ConPTY terminal), `PickUpInTerminalAsync`, `ToggleManual` (per-task manual flag) and `SyncInteractiveSessions` (mirrors Mission Control's open ConPTY panes onto the rows); MyDay extras (`IsMyDayList`, `ClearDayCommand`, `ShowPrepLogCommand`) and the pinned Notes pseudo-row (`ShowNotesRow`, `OpenNotesCommand`). Raises `NotesRequested`/`PrepRequested` events consumed by the shell.
|
||||||
- **DetailsIslandViewModel** — the detail pane for a bound `TaskRowViewModel`. Owns live-log streaming (`Log` via `StreamLineFormatter`), debounced title/description editing, subtasks, session-outcome/roadblock split (splits `Result` at the roadblock marker into two cards), the three-tab work console (`output`/`git`/`session`), child surfacing (`ChildOutcomes` rows plus `ChildrenNeedingAttention`/`HasChildrenNeedingAttention` — children that failed, were cancelled, await review, or reported roadblocks — drive an attention band on the Session tab, which is only visible when `HasChildOutcomes`), and the modes: `IsNotesMode` (hosts `NotesEditorViewModel`), `IsPrepMode`, computed `IsTaskDetailVisible = !IsNotesMode && !IsPrepMode`. Three concerns are extracted into section VMs exposed as properties: **AgentConfigEditorViewModel** (scope=Task; per-task Model/MaxTurns/AgentPath overrides with `InheritedBadge` + `InheritanceResolver`, additive SystemPrompt, debounced auto-save; exposed as `AgentSettings`), **MergeSectionViewModel** (merge-target selection, mergeability indicator via `MergePreviewPresenter` over `PreviewMergeAsync`, `OpenDiffAsync` and `ReviewCombinedDiffCommand` — both build a `DiffViewerViewModel`, call `ShowDiffViewer`, and fire the `DiffViewed` callback; `HasReviewableDiff` reports whether anything is inspectable, feeding the review gate), **PrepPanelViewModel** (daily-prep panel: `PrepLog`, `PlanDayCommand` → `RunDailyPrepNowAsync`, persisted last run via `GetLastPrepLogAsync`). Attachments: `Attachments` (`ObservableCollection<AttachmentRowViewModel>`), `IsDragOver`, `DropStatus`, `CanAcceptDrop`, `AddFilesAsync`, `RemoveAttachmentCommand`; loads on task change; `ComposedPreview` includes attachment paths. Writes directly via `new AttachmentStore()` + `new TaskAttachmentRepository(ctx)`. Helper rows (`ChildOutcomeRowViewModel`, `SubtaskRowViewModel`, `LogLineViewModel`, `AttachmentRowViewModel`) live in the same file.
|
- **DetailsIslandViewModel** — the detail pane for a bound `TaskRowViewModel`. Owns live-log streaming (`Log` via `StreamLineFormatter`), debounced title/description editing, subtasks, session-outcome/roadblock split (splits `Result` at the roadblock marker into two cards), the three-tab work console (`output`/`git`/`session`), child surfacing (`ChildOutcomes` rows plus `ChildrenNeedingAttention`/`HasChildrenNeedingAttention` — children that failed, were cancelled, await review, or reported roadblocks — drive an attention band on the Session tab, which is only visible when `HasChildOutcomes`), and the modes: `IsNotesMode` (hosts `NotesEditorViewModel`), `IsPrepMode`, computed `IsTaskDetailVisible = !IsNotesMode && !IsPrepMode`. Three concerns are extracted into section VMs exposed as properties: **AgentConfigEditorViewModel** (scope=Task; per-task Model/MaxTurns/AgentPath overrides with `InheritedBadge` + `InheritanceResolver`, additive SystemPrompt, debounced auto-save; exposed as `AgentSettings`), **MergeSectionViewModel** (merge-target selection, mergeability indicator via `MergePreviewPresenter` over `PreviewMergeAsync`, `OpenDiffAsync` and `ReviewCombinedDiffCommand` — both build a `DiffViewerViewModel`, call `ShowDiffViewer`, and fire the `DiffViewed` callback; `HasReviewableDiff` reports whether anything is inspectable, feeding the review gate), **PrepPanelViewModel** (daily-prep panel: `PrepLog`, `PlanDayCommand` → `RunDailyPrepNowAsync`, persisted last run via `GetLastPrepLogAsync`). Attachments: `Attachments` (`ObservableCollection<AttachmentRowViewModel>`), `IsDragOver`, `DropStatus`, `CanAcceptDrop`, `AddFilesAsync`, `RemoveAttachmentCommand`; loads on task change; `ComposedPreview` includes attachment paths. Writes directly via `new AttachmentStore()` + `new TaskAttachmentRepository(ctx)`. Helper rows (`ChildOutcomeRowViewModel`, `SubtaskRowViewModel`, `LogLineViewModel`, `AttachmentRowViewModel`) live in the same file.
|
||||||
- **TaskRowViewModel** / **ListNavItemViewModel** — lightweight display VMs (task row: status, planning phase, parent/blocked links, roadblock count, computed `IsDraft`/`IsPlanned`/`IsChild`/`IsPlanningParent`/`CanRefine`; list row: kind Smart/Virtual/User, count, icon/dot keys, drop hints).
|
- **TaskRowViewModel** / **ListNavItemViewModel** — lightweight display VMs (task row: status, planning phase, parent/blocked links, roadblock count, computed `IsDraft`/`IsPlanned`/`IsChild`/`IsPlanningParent`/`CanRefine`; plus `IsManual` (→ MANUAL badge; suppresses `CanSendToQueue`/`CanRefine`/`CanOpenPlanningSession`) and `HasInteractiveSession` (→ accent "Interactive" chip instead of "Parked"; tapping it jumps to that Mission Control pane); list row: kind Smart/Virtual/User, count, icon/dot keys, drop hints, `IsManual`).
|
||||||
- **NotesEditorViewModel** — day navigator + bullet CRUD for daily notes via `INotesApi`.
|
- **NotesEditorViewModel** — day navigator + bullet CRUD for daily notes via `INotesApi`.
|
||||||
- **Modal VMs** — `SettingsModalViewModel` (four tabs: General, Worktrees, Files prompt-paths, Prime Claude incl. `DailyPrepMaxTasks` + prime-schedule rows), `ListSettingsModalViewModel` (name, working dir, commit type, delete list; hosts shared `AgentConfigEditorViewModel` as `Agent` property (scope=List) — save delegates to `Agent.SaveAsync()`), `RepoImportModalViewModel` (bulk-create lists from git repos found under chosen parents; already-wired repos disabled), `WeeklyReportModalViewModel` (range pickers default "since last standup weekday → today", cached per range, markdown via MarkdownView), `MergeModalViewModel` (single-task merge form, called from the diff modal), `WorktreesOverviewModalViewModel` (global/per-list worktree rows, batch merge + state ops), `UnfinishedPlanningModalViewModel` (Resume/FinalizeNow/Discard for a draft planning session), `WorkerConnectionModalViewModel` (offline help), `AboutModalViewModel`, `LogVisualizerViewModel` (worker logs, last 30 min, all levels + a warn/error-only filter; loads via `GetRecentLogsAsync`).
|
- **Modal VMs** — `SettingsModalViewModel` (four tabs: General, Worktrees, Files prompt-paths, Prime Claude incl. `DailyPrepMaxTasks` + prime-schedule rows). General hosts the per-model preset table (`ModelPresets` → `ModelPresetRowViewModel`: effort + max turns per alias) which **replaced** the single global "Max turns" field, `ListSettingsModalViewModel` (name, working dir, commit type, "manual list" flag, delete list; hosts shared `AgentConfigEditorViewModel` as `Agent` property (scope=List) — save delegates to `Agent.SaveAsync()`), `RepoImportModalViewModel` (bulk-create lists from git repos found under chosen parents; already-wired repos disabled), `WeeklyReportModalViewModel` (range pickers default "since last standup weekday → today", cached per range, markdown via MarkdownView), `MergeModalViewModel` (single-task merge form, called from the diff modal), `WorktreesOverviewModalViewModel` (global/per-list worktree rows, batch merge + state ops), `UnfinishedPlanningModalViewModel` (Resume/FinalizeNow/Discard for a draft planning session), `MergeHelperSelectionModalViewModel` ("Let Claude handle it": checkbox picker over one list's non-terminal, non-manual tasks, pre-ticks the actionable ones; list-scoped only — `Configure(listId, listName)`, no global scope. Opened from the list row's context menu, which is hidden when the list has no working dir; on confirm `ListsIslandViewModel` raises `LetClaudeHandleRequested` → shell → `MissionControlViewModel.OpenMergeHelperConPtySessionAsync` opens an ad-hoc ConPTY tile running the five-phase handler prompt), `WorkerConnectionModalViewModel` (offline help), `AboutModalViewModel`, `LogVisualizerViewModel` (worker logs, last 30 min, all levels + a warn/error-only filter; loads via `GetRecentLogsAsync`).
|
||||||
- **Diff stack** — `UnifiedDiffParser` (static; parses `git diff` output into `DiffFileViewModel`s, detecting added/deleted/renamed/binary files and per-line numbers; `Flatten` injects file-header rows for a combined single-pane view). `DiffModels.cs` holds shared types: `DiffLineViewModel`, `DiffFileViewModel`, `DiffLineKind`, `DiffFileStatus`, `SubtaskDiffRow`, `DiffTreeNodeViewModel`, `DiffTree`. `DiffViewerViewModel` is a single unified read-only diff viewer with two modes: **Files** (dirty worktree / branch-vs-base / commit-range — loads via GitService, shows a folder file-tree on the left + per-file diff pane on the right, Merge button for live branch source) and **Planning** (per-subtask diffs via `GetPlanningAggregateAsync`, subtask list left + flat diff right, combined integration-branch toggle). The Merge button opens the merge form, which routes to `ConflictResolverViewModel` on conflict. `DiffLinesView` renders per-file diff content with binary/empty placeholders.
|
- **Diff stack** — `UnifiedDiffParser` (static; parses `git diff` output into `DiffFileViewModel`s, detecting added/deleted/renamed/binary files and per-line numbers; `Flatten` injects file-header rows for a combined single-pane view). `DiffModels.cs` holds shared types: `DiffLineViewModel`, `DiffFileViewModel`, `DiffLineKind`, `DiffFileStatus`, `SubtaskDiffRow`, `DiffTreeNodeViewModel`, `DiffTree`. `DiffViewerViewModel` is a single unified read-only diff viewer with two modes: **Files** (dirty worktree / branch-vs-base / commit-range — loads via GitService, shows a folder file-tree on the left + per-file diff pane on the right, Merge button for live branch source) and **Planning** (per-subtask diffs via `GetPlanningAggregateAsync`, subtask list left + flat diff right, combined integration-branch toggle). The Merge button opens the merge form, which routes to `ConflictResolverViewModel` on conflict. `DiffLinesView` renders per-file diff content with binary/empty placeholders.
|
||||||
- **Conflicts** — `ConflictResolverViewModel` (in-app **Rider-style 3-pane merge editor** for both single-task and planning unit-merge conflicts: single-task starts the conflict merge, parses each conflicted file into stable/conflict `MergeFileSegment`s via the worker's `GetMergeConflictDocuments`; exposes the active file's three reconstructed documents — `ActiveOursText` / `ActiveResultText` / `ActiveTheirsText` (from `MergeFile.OursText/ResultText/TheirsText`; Result seeds unresolved conflicts with Ours) — plus `ActiveFile`/`SelectFileCommand` (multi-file switcher), `Current`/`Next`/`Previous` (focused-conflict nav), a per-active-file `PositionText` readout, per-block `AcceptOurs/Theirs/Both/Base` + `MergeFile.Compose`, and `CanContinue` gated on every file resolved + no binary; writes each file via `WriteConflictResolution`, continue/abort; **planning mode** via `OpenForPlanningAsync(parentId, subtaskId)` loads the current subtask's mid-merge conflicts without re-starting the merge and routes continue/abort to `ContinuePlanningMerge`/`AbortPlanningMerge`, so a unit-merge conflict re-opens the editor per subtask via the `PlanningMergeConflict` broadcast). The view (`Views/Conflicts/ConflictResolverView`) shows the whole file in three **AvaloniaEdit** panes — MAIN/ours (read-only) | editable Result | INCOMING/theirs (read-only) — with TextMate highlighting by extension (theme `StyleInclude` in `App.axaml`); a code-behind `IBackgroundRenderer` tints each conflict block (unresolved/resolved) across panes, an `IReadOnlySectionProvider` + `TextAnchor` regions keep only conflict spans editable in Result (edits flow back to the block); each unresolved conflict starts EMPTY (a thin marker bar); the between-pane gutter controls **toggle** each side in/out of the result — `›`/`‹` add MAIN/INCOMING in click order (first pick on top), clicking again removes that side — so a conflict can take main, incoming, both, or neither; a `FilesSummary` readout shows how many files still have conflicts, and the three panes share a proportional synced vertical scroll. A conflict overview ruler right of the Result pane (`ConflictMap`) maps every conflict in the file proportionally (click a tick to jump) — handy for long files. Conflict block tints live in `Tokens.axaml` (`Merge*TintBrush`). The editor is reached from review **Approve** on conflict and from the **Merge** button in the Diff window (a conflicting `MergeTask` hands off to the resolver via `RequestConflictResolution`).
|
- **Conflicts** — `ConflictResolverViewModel` (in-app **Rider-style 3-pane merge editor** for both single-task and planning unit-merge conflicts: single-task starts the conflict merge, parses each conflicted file into stable/conflict `MergeFileSegment`s via the worker's `GetMergeConflictDocuments`; exposes the active file's three reconstructed documents — `ActiveOursText` / `ActiveResultText` / `ActiveTheirsText` (from `MergeFile.OursText/ResultText/TheirsText`; Result seeds unresolved conflicts with Ours) — plus `ActiveFile`/`SelectFileCommand` (multi-file switcher), `Current`/`Next`/`Previous` (focused-conflict nav), a per-active-file `PositionText` readout, per-block `AcceptOurs/Theirs/Both/Base` + `MergeFile.Compose`, and `CanContinue` gated on every file resolved + no binary; writes each file via `WriteConflictResolution`, continue/abort; **planning mode** via `OpenForPlanningAsync(parentId, subtaskId)` loads the current subtask's mid-merge conflicts without re-starting the merge and routes continue/abort to `ContinuePlanningMerge`/`AbortPlanningMerge`, so a unit-merge conflict re-opens the editor per subtask via the `PlanningMergeConflict` broadcast). The view (`Views/Conflicts/ConflictResolverView`) shows the whole file in three **AvaloniaEdit** panes — MAIN/ours (read-only) | editable Result | INCOMING/theirs (read-only) — with TextMate highlighting by extension (theme `StyleInclude` in `App.axaml`); a code-behind `IBackgroundRenderer` tints each conflict block (unresolved/resolved) across panes, an `IReadOnlySectionProvider` + `TextAnchor` regions keep only conflict spans editable in Result (edits flow back to the block); each unresolved conflict starts EMPTY (a thin marker bar); the between-pane gutter controls **toggle** each side in/out of the result — `›`/`‹` add MAIN/INCOMING in click order (first pick on top), clicking again removes that side — so a conflict can take main, incoming, both, or neither; a `FilesSummary` readout shows how many files still have conflicts, and the three panes share a proportional synced vertical scroll. A conflict overview ruler right of the Result pane (`ConflictMap`) maps every conflict in the file proportionally (click a tick to jump) — handy for long files. Conflict block tints live in `Tokens.axaml` (`Merge*TintBrush`). The editor is reached from review **Approve** on conflict and from the **Merge** button in the Diff window (a conflicting `MergeTask` hands off to the resolver via `RequestConflictResolution`).
|
||||||
|
|
||||||
@@ -65,5 +65,8 @@ Modals use `TaskCompletionSource` results behind the reusable `ModalShell` contr
|
|||||||
- Context menus exist on both list rows and task rows; right-click selects before opening the menu
|
- Context menus exist on both list rows and task rows; right-click selects before opening the menu
|
||||||
- "Run Now" CanExecute re-evaluates when worker connection state changes
|
- "Run Now" CanExecute re-evaluates when worker connection state changes
|
||||||
- Icon gotcha: `PathIcon` fills geometry. Line-art/stroke icons must be defined as filled geometry or rendered as a stroked `Path` (e.g. `Icon.PlanDay` via the `Path.plan-icon` style); a pure stroke path used with `PathIcon` is invisible.
|
- Icon gotcha: `PathIcon` fills geometry. Line-art/stroke icons must be defined as filled geometry or rendered as a stroked `Path` (e.g. `Icon.PlanDay` via the `Path.plan-icon` style); a pure stroke path used with `PathIcon` is invisible.
|
||||||
|
- Window key bindings live on `MainWindow`: `Ctrl+K` focuses search, `Ctrl+N` the add-task box. Do **not** bind bare punctuation gestures — `OemQuestion` used to hold search focus and silently swallowed `#` app-wide on a German layout.
|
||||||
|
- `Ellipse.spinner` (IslandStyles) is the shared indeterminate spinner: used for a starting ConPTY pane (`InteractiveTerminalViewModel.IsStarting`) and in place of the refine button while `TaskRowViewModel.IsRefining`.
|
||||||
|
- `ConPtyPaneViewModel` resolves its own launch spec (ctor takes a descriptor **factory**; the host wires handlers and then calls `Start()`), so the Mission Control tile appears immediately with its spinner while the worker is still preparing the worktree. A failed launch keeps the tile with its inline error banner instead of never appearing.
|
||||||
- `SessionTerminalView` is the reusable log terminal (StyledProperties `Entries`, `Label`, `IsRunning`, `IsDone`, `IsFailed`) used for both the task `Log` and the prep `PrepLog`.
|
- `SessionTerminalView` is the reusable log terminal (StyledProperties `Entries`, `Label`, `IsRunning`, `IsDone`, `IsFailed`) used for both the task `Log` and the prep `PrepLog`.
|
||||||
- `DetailsIslandView` is a pane-wide drag-and-drop file target (`DragDrop.AllowDrop`, Avalonia 12 `DataFormat.File`) with a "Drop to attach" hover overlay. `DescriptionStepsCard` shows an Attachments list (file name, size, remove button), an "Add file…" picker, and an explicit `DropStatus` confirmation line. Keys use the `details.attachments.*` localization namespace (en + de).
|
- `DetailsIslandView` is a pane-wide drag-and-drop file target (`DragDrop.AllowDrop`, Avalonia 12 `DataFormat.File`) with a "Drop to attach" hover overlay. `DescriptionStepsCard` shows an Attachments list (file name, size, remove button), an "Add file…" picker, and an explicit `DropStatus` confirmation line. Keys use the `details.attachments.*` localization namespace (en + de).
|
||||||
|
|||||||
@@ -116,6 +116,7 @@
|
|||||||
<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}"/>
|
||||||
<SolidColorBrush x:Key="PlannedBadgeBrush" Color="{StaticResource SageColor}"/>
|
<SolidColorBrush x:Key="PlannedBadgeBrush" Color="{StaticResource SageColor}"/>
|
||||||
|
<SolidColorBrush x:Key="ManualBadgeBrush" Color="{StaticResource TextFaintColor}"/>
|
||||||
|
|
||||||
</Styles.Resources>
|
</Styles.Resources>
|
||||||
|
|
||||||
@@ -247,6 +248,21 @@
|
|||||||
<Setter Property="Foreground" Value="#8FB9D6" />
|
<Setter Property="Foreground" Value="#8FB9D6" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<!-- interactive → accent, the "live" family shared with running: the task has a ConPTY session
|
||||||
|
open in Mission Control. Clickable (jumps to that pane), hence the hand cursor + hover lift.
|
||||||
|
Deliberately NOT the slate-blue parked treatment — telling the two apart is the whole point. -->
|
||||||
|
<Style Selector="Border.chip.interactive">
|
||||||
|
<Setter Property="Background" Value="{StaticResource RunningTintBrush}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
|
||||||
|
<Setter Property="Cursor" Value="Hand" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.chip.interactive > TextBlock">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource AccentBrush}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.chip.interactive:pointerover">
|
||||||
|
<Setter Property="Background" Value="{StaticResource AccentSoftBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
<!-- Worktree-state chips (worktrees overview) -->
|
<!-- Worktree-state chips (worktrees overview) -->
|
||||||
<!-- active → slate-blue (same hue as parked: a live worktree) -->
|
<!-- active → slate-blue (same hue as parked: a live worktree) -->
|
||||||
<Style Selector="Border.chip.wt-active">
|
<Style Selector="Border.chip.wt-active">
|
||||||
@@ -454,6 +470,25 @@
|
|||||||
</Style.Animations>
|
</Style.Animations>
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<!-- Indeterminate ring spinner (dashed ring, rotated forever) -->
|
||||||
|
<Style Selector="Ellipse.spinner">
|
||||||
|
<Setter Property="Width" Value="14" />
|
||||||
|
<Setter Property="Height" Value="14" />
|
||||||
|
<Setter Property="Stroke" Value="{StaticResource AccentBrush}" />
|
||||||
|
<Setter Property="StrokeThickness" Value="2" />
|
||||||
|
<Setter Property="StrokeDashArray" Value="3,2" />
|
||||||
|
<Setter Property="RenderTransform" Value="rotate(0deg)" />
|
||||||
|
<Style.Animations>
|
||||||
|
<Animation Duration="0:0:0.9" IterationCount="INFINITE" Easing="LinearEasing">
|
||||||
|
<KeyFrame Cue="0%"> <Setter Property="RotateTransform.Angle" Value="0" /> </KeyFrame>
|
||||||
|
<KeyFrame Cue="100%"><Setter Property="RotateTransform.Angle" Value="360" /></KeyFrame>
|
||||||
|
</Animation>
|
||||||
|
</Style.Animations>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Ellipse.spinner.dim">
|
||||||
|
<Setter Property="Stroke" Value="{StaticResource TextDimBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
<!-- ============================================================ -->
|
<!-- ============================================================ -->
|
||||||
<!-- AGENT STRIP -->
|
<!-- AGENT STRIP -->
|
||||||
<!-- ============================================================ -->
|
<!-- ============================================================ -->
|
||||||
@@ -997,6 +1032,11 @@
|
|||||||
<Setter Property="Background" Value="{DynamicResource PlannedBadgeBrush}"/>
|
<Setter Property="Background" Value="{DynamicResource PlannedBadgeBrush}"/>
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<!-- manual → faint gray: a reminder Claude never touches -->
|
||||||
|
<Style Selector="Border.badge.manual">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource ManualBadgeBrush}"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
<!-- ============================================================ -->
|
<!-- ============================================================ -->
|
||||||
<!-- SHARED MODAL STYLES (promoted from per-modal Window.Styles) -->
|
<!-- SHARED MODAL STYLES (promoted from per-modal Window.Styles) -->
|
||||||
<!-- ============================================================ -->
|
<!-- ============================================================ -->
|
||||||
|
|||||||
@@ -86,8 +86,8 @@ public interface IWorkerClient : INotifyPropertyChanged
|
|||||||
/// no task, no worktree.</summary>
|
/// no task, no worktree.</summary>
|
||||||
Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default);
|
Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default);
|
||||||
/// <summary>Launch spec for an embedded ConPTY "merge helper" session that drives the given
|
/// <summary>Launch spec for an embedded ConPTY "merge helper" session that drives the given
|
||||||
/// tasks to a merged/Done state. listId scopes the session to that list; null = all lists.</summary>
|
/// tasks to a merged/Done state. listId scopes the session (and cwd) to that list.</summary>
|
||||||
Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string? listId, CancellationToken ct = default);
|
Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, 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);
|
||||||
|
|||||||
@@ -522,7 +522,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
public async Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
|
public async Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
|
||||||
=> await _hub.InvokeAsync<LaunchSpec>("GetAdHocLaunchSpec", directory, ct);
|
=> await _hub.InvokeAsync<LaunchSpec>("GetAdHocLaunchSpec", directory, ct);
|
||||||
|
|
||||||
public async Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string? listId, CancellationToken ct = default)
|
public async Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, CancellationToken ct = default)
|
||||||
=> await _hub.InvokeAsync<LaunchSpec>("GetMergeHelperLaunchSpec", taskIds, listId, ct);
|
=> await _hub.InvokeAsync<LaunchSpec>("GetMergeHelperLaunchSpec", taskIds, listId, ct);
|
||||||
|
|
||||||
public async Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default)
|
public async Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default)
|
||||||
@@ -607,7 +607,11 @@ public sealed record AppSettingsDto(
|
|||||||
string? ReportExcludedPaths,
|
string? ReportExcludedPaths,
|
||||||
int StandupWeekday,
|
int StandupWeekday,
|
||||||
int DailyPrepMaxTasks,
|
int DailyPrepMaxTasks,
|
||||||
List<string>? SessionSkills = null);
|
List<string>? SessionSkills = null,
|
||||||
|
List<ModelPresetDto>? ModelPresets = null);
|
||||||
|
|
||||||
|
// Per-model run defaults (effort + turn budget) edited in Settings → General.
|
||||||
|
public sealed record ModelPresetDto(string Model, string Effort, int MaxTurns);
|
||||||
|
|
||||||
public sealed record SessionSkillDto(
|
public sealed record SessionSkillDto(
|
||||||
string Name,
|
string Name,
|
||||||
@@ -624,7 +628,7 @@ public record MergeTargetsDto(string DefaultBranch, IReadOnlyList<string> LocalB
|
|||||||
public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList<ConflictDocumentDto> Files);
|
public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList<ConflictDocumentDto> Files);
|
||||||
public record ConflictDocumentDto(string Path, bool IsBinary, IReadOnlyList<MergeSegmentDto> Segments);
|
public record ConflictDocumentDto(string Path, bool IsBinary, IReadOnlyList<MergeSegmentDto> Segments);
|
||||||
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 sealed record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType);
|
public sealed record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType, bool IsManual = false);
|
||||||
public sealed record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
|
public sealed record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
|
||||||
public sealed record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
|
public sealed record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
|
||||||
public sealed record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
|
public sealed record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
|
||||||
|
|||||||
@@ -47,7 +47,11 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
|
|||||||
[ObservableProperty] private string _effectiveSystemPromptHint = "";
|
[ObservableProperty] private string _effectiveSystemPromptHint = "";
|
||||||
|
|
||||||
private string _globalModel = ModelRegistry.DefaultAlias;
|
private string _globalModel = ModelRegistry.DefaultAlias;
|
||||||
private int _globalMaxTurns = 100;
|
// The global max-turns default is per-model (Settings -> General), so it moves with whichever
|
||||||
|
// model actually ends up in effect here.
|
||||||
|
private IReadOnlyList<ModelPreset> _presets = ModelPresets.Defaults;
|
||||||
|
private string EffectiveModel => Model ?? _listModel ?? _globalModel;
|
||||||
|
private int GlobalMaxTurns => ModelPresets.For(_presets, EffectiveModel).MaxTurns;
|
||||||
private string? _listModel; // Task scope only
|
private string? _listModel; // Task scope only
|
||||||
private int? _listMaxTurns; // Task scope only
|
private int? _listMaxTurns; // Task scope only
|
||||||
private string? _listAgentName; // Task scope only
|
private string? _listAgentName; // Task scope only
|
||||||
@@ -56,7 +60,7 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
|
|||||||
private CancellationTokenSource? _saveCts;
|
private CancellationTokenSource? _saveCts;
|
||||||
|
|
||||||
public int EffectiveMaxTurns =>
|
public int EffectiveMaxTurns =>
|
||||||
MaxTurns is decimal t ? (int)t : (_listMaxTurns ?? _globalMaxTurns);
|
MaxTurns is decimal t ? (int)t : (_listMaxTurns ?? GlobalMaxTurns);
|
||||||
|
|
||||||
public ObservableCollection<string> ModelOptions { get; } = new(ModelRegistry.Aliases);
|
public ObservableCollection<string> ModelOptions { get; } = new(ModelRegistry.Aliases);
|
||||||
public ObservableCollection<AgentInfo> Agents { get; } = new();
|
public ObservableCollection<AgentInfo> Agents { get; } = new();
|
||||||
@@ -97,7 +101,14 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
|
|||||||
foreach (var s in SessionSkills) s.PropertyChanged -= OnSkillItemPropertyChanged;
|
foreach (var s in SessionSkills) s.PropertyChanged -= OnSkillItemPropertyChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnModelChanged(string? value) { RecomputeModelBadge(); QueueSave(); }
|
partial void OnModelChanged(string? value)
|
||||||
|
{
|
||||||
|
RecomputeModelBadge();
|
||||||
|
// A different model means a different global turn default.
|
||||||
|
RecomputeTurnsBadge();
|
||||||
|
OnPropertyChanged(nameof(EffectiveMaxTurns));
|
||||||
|
QueueSave();
|
||||||
|
}
|
||||||
|
|
||||||
partial void OnMaxTurnsChanged(decimal? value)
|
partial void OnMaxTurnsChanged(decimal? value)
|
||||||
{
|
{
|
||||||
@@ -130,8 +141,8 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
|
|||||||
{
|
{
|
||||||
var own = MaxTurns?.ToString();
|
var own = MaxTurns?.ToString();
|
||||||
var (value, source) = _scope == AgentConfigScope.Task
|
var (value, source) = _scope == AgentConfigScope.Task
|
||||||
? InheritanceResolver.Resolve(own, _listMaxTurns?.ToString(), _globalMaxTurns.ToString())
|
? InheritanceResolver.Resolve(own, _listMaxTurns?.ToString(), GlobalMaxTurns.ToString())
|
||||||
: InheritanceResolver.ResolveList(own, _globalMaxTurns.ToString());
|
: InheritanceResolver.ResolveList(own, GlobalMaxTurns.ToString());
|
||||||
TurnsInheritedHint = value;
|
TurnsInheritedHint = value;
|
||||||
TurnsBadge = BadgeFor(source, MaxTurns is not null);
|
TurnsBadge = BadgeFor(source, MaxTurns is not null);
|
||||||
}
|
}
|
||||||
@@ -284,7 +295,9 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
|
|||||||
{
|
{
|
||||||
var app = await _worker.GetAppSettingsAsync();
|
var app = await _worker.GetAppSettingsAsync();
|
||||||
_globalModel = app?.DefaultModel ?? ModelRegistry.DefaultAlias;
|
_globalModel = app?.DefaultModel ?? ModelRegistry.DefaultAlias;
|
||||||
_globalMaxTurns = app?.DefaultMaxTurns ?? 100;
|
_presets = app?.ModelPresets is { Count: > 0 } rows
|
||||||
|
? rows.Select(r => new ModelPreset(r.Model, r.Effort, r.MaxTurns)).ToList()
|
||||||
|
: ModelPresets.Defaults;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ApplyConfig(string? model, int? maxTurns, string? systemPrompt, string? agentPath)
|
private void ApplyConfig(string? model, int? maxTurns, string? systemPrompt, string? agentPath)
|
||||||
|
|||||||
@@ -22,6 +22,15 @@ public sealed partial class InteractiveTerminalViewModel : ViewModelBase, IDispo
|
|||||||
[ObservableProperty] private int? _exitCode;
|
[ObservableProperty] private int? _exitCode;
|
||||||
[ObservableProperty] private string? _startError;
|
[ObservableProperty] private string? _startError;
|
||||||
|
|
||||||
|
/// <summary>True from construction until the child process is actually launched (or the launch
|
||||||
|
/// failed) — covers both the caller's launch-spec roundtrip and the ConPTY spawn, so the host
|
||||||
|
/// can show a spinner instead of an empty black pane.</summary>
|
||||||
|
public bool IsStarting => !IsRunning && !HasExited && StartError is null;
|
||||||
|
|
||||||
|
partial void OnIsRunningChanged(bool value) => OnPropertyChanged(nameof(IsStarting));
|
||||||
|
partial void OnHasExitedChanged(bool value) => OnPropertyChanged(nameof(IsStarting));
|
||||||
|
partial void OnStartErrorChanged(string? value) => OnPropertyChanged(nameof(IsStarting));
|
||||||
|
|
||||||
public InteractiveTerminalViewModel()
|
public InteractiveTerminalViewModel()
|
||||||
{
|
{
|
||||||
_session.ProcessExited += OnSessionProcessExited;
|
_session.ProcessExited += OnSessionProcessExited;
|
||||||
@@ -74,6 +83,15 @@ public sealed partial class InteractiveTerminalViewModel : ViewModelBase, IDispo
|
|||||||
ExitCode = exitCode;
|
ExitCode = exitCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Reports a failure that happened before <see cref="Start"/> could be called (e.g. the
|
||||||
|
/// launch-spec roundtrip threw), so it surfaces through the same banner as a spawn failure.</summary>
|
||||||
|
public void ReportStartFailure(string message)
|
||||||
|
{
|
||||||
|
IsRunning = false;
|
||||||
|
HasExited = true;
|
||||||
|
StartError = message;
|
||||||
|
}
|
||||||
|
|
||||||
public void Kill() => _session.Kill();
|
public void Kill() => _session.Kill();
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
|
|||||||
@@ -258,6 +258,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
private bool _suppressDescSave;
|
private bool _suppressDescSave;
|
||||||
private CancellationTokenSource? _descSaveCts;
|
private CancellationTokenSource? _descSaveCts;
|
||||||
|
|
||||||
|
private bool _suppressTitleSave;
|
||||||
|
private CancellationTokenSource? _titleSaveCts;
|
||||||
|
|
||||||
// Set by shell so CloseDetailCommand can clear SelectedTask
|
// Set by shell so CloseDetailCommand can clear SelectedTask
|
||||||
public Action? CloseDetail { get; set; }
|
public Action? CloseDetail { get; set; }
|
||||||
|
|
||||||
@@ -430,6 +433,39 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
partial void OnEditableTitleChanged(string value)
|
||||||
|
{
|
||||||
|
if (_suppressTitleSave || Task is null) return;
|
||||||
|
_titleSaveCts?.Cancel();
|
||||||
|
_titleSaveCts = new CancellationTokenSource();
|
||||||
|
// Capture the row so a task switch mid-debounce cannot retitle the new task.
|
||||||
|
_ = SaveTitleAsync(Task, value, _titleSaveCts.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async System.Threading.Tasks.Task SaveTitleAsync(
|
||||||
|
TaskRowViewModel row, string value, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await System.Threading.Tasks.Task.Delay(400, ct);
|
||||||
|
var title = value?.Trim();
|
||||||
|
// An empty title would make the row unidentifiable — keep the stored one.
|
||||||
|
if (string.IsNullOrEmpty(title)) return;
|
||||||
|
await using var ctx = _dbFactory.CreateDbContext();
|
||||||
|
var repo = new TaskRepository(ctx);
|
||||||
|
var entity = await repo.GetByIdAsync(row.Id);
|
||||||
|
if (entity is null || entity.Title == title) return;
|
||||||
|
entity.Title = title;
|
||||||
|
await repo.UpdateAsync(entity);
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
row.Title = title;
|
||||||
|
if (ReferenceEquals(Task, row))
|
||||||
|
Merge.SyncTaskContext(row.Id, title, row.IsPlanningParent);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { }
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
partial void OnEditableDescriptionChanged(string value)
|
partial void OnEditableDescriptionChanged(string value)
|
||||||
{
|
{
|
||||||
if (_suppressDescSave || Task is null) return;
|
if (_suppressDescSave || Task is null) return;
|
||||||
@@ -525,7 +561,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
ct.ThrowIfCancellationRequested();
|
ct.ThrowIfCancellationRequested();
|
||||||
if (entity == null) return;
|
if (entity == null) return;
|
||||||
|
|
||||||
EditableTitle = entity.Title;
|
_suppressTitleSave = true;
|
||||||
|
try { EditableTitle = entity.Title; }
|
||||||
|
finally { _suppressTitleSave = false; }
|
||||||
_suppressDescSave = true;
|
_suppressDescSave = true;
|
||||||
try { EditableDescription = entity.Description ?? ""; }
|
try { EditableDescription = entity.Description ?? ""; }
|
||||||
finally { _suppressDescSave = false; }
|
finally { _suppressDescSave = false; }
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ public sealed partial class ListNavItemViewModel : ViewModelBase
|
|||||||
[ObservableProperty] private bool _isActive;
|
[ObservableProperty] private bool _isActive;
|
||||||
[ObservableProperty] private string? _workingDir;
|
[ObservableProperty] private string? _workingDir;
|
||||||
[ObservableProperty] private string _defaultCommitType = CommitTypeRegistry.DefaultType;
|
[ObservableProperty] private string _defaultCommitType = CommitTypeRegistry.DefaultType;
|
||||||
|
// Reminder list: tasks created here default to manual.
|
||||||
|
[ObservableProperty] private bool _isManual;
|
||||||
[ObservableProperty] private bool _dropHintAbove;
|
[ObservableProperty] private bool _dropHintAbove;
|
||||||
[ObservableProperty] private bool _dropHintBelow;
|
[ObservableProperty] private bool _dropHintBelow;
|
||||||
public string? IconKey { get; init; }
|
public string? IconKey { get; init; }
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ namespace ClaudeDo.Ui.ViewModels.Islands;
|
|||||||
|
|
||||||
public enum ListKind { Smart, Virtual, User }
|
public enum ListKind { Smart, Virtual, User }
|
||||||
|
|
||||||
/// <summary>Confirmed merge-helper run: the scope list (null = all lists) and the ordered selected task ids.</summary>
|
/// <summary>Confirmed handler run: the scope list and the ordered selected task ids.</summary>
|
||||||
public sealed record MergeHelperRequest(string? ListId, IReadOnlyList<string> TaskIds);
|
public sealed record MergeHelperRequest(string ListId, IReadOnlyList<string> TaskIds);
|
||||||
|
|
||||||
public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||||
{
|
{
|
||||||
@@ -47,7 +47,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);
|
await vm.LoadAsync(rawId, row.Name, row.WorkingDir, row.DefaultCommitType, row.IsManual);
|
||||||
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);
|
||||||
@@ -100,18 +100,6 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
LetClaudeHandleRequested?.Invoke(new MergeHelperRequest(rawId, ids));
|
LetClaudeHandleRequested?.Invoke(new MergeHelperRequest(rawId, ids));
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private async Task LetClaudeHandleAllAsync()
|
|
||||||
{
|
|
||||||
if (Dialogs is null || _services is null) return;
|
|
||||||
var vm = _services.GetRequiredService<MergeHelperSelectionModalViewModel>();
|
|
||||||
vm.Configure(null, null);
|
|
||||||
await vm.LoadAsync();
|
|
||||||
var ids = await Dialogs.ShowMergeHelperSelectionAsync(vm);
|
|
||||||
if (ids is { Count: > 0 })
|
|
||||||
LetClaudeHandleRequested?.Invoke(new MergeHelperRequest(null, ids));
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void OpenInExplorer(ListNavItemViewModel? row)
|
private void OpenInExplorer(ListNavItemViewModel? row)
|
||||||
{
|
{
|
||||||
@@ -255,6 +243,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
DotColorKey = dotColors[idx % dotColors.Length],
|
DotColorKey = dotColors[idx % dotColors.Length],
|
||||||
WorkingDir = l.WorkingDir,
|
WorkingDir = l.WorkingDir,
|
||||||
DefaultCommitType = l.DefaultCommitType,
|
DefaultCommitType = l.DefaultCommitType,
|
||||||
|
IsManual = l.IsManual,
|
||||||
};
|
};
|
||||||
Items.Add(item);
|
Items.Add(item);
|
||||||
UserLists.Add(item);
|
UserLists.Add(item);
|
||||||
@@ -329,7 +318,7 @@ 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);
|
await vm.LoadAsync(entity.Id, entity.Name, entity.WorkingDir, entity.DefaultCommitType, entity.IsManual);
|
||||||
await Dialogs.ShowListSettingsAsync(vm);
|
await Dialogs.ShowListSettingsAsync(vm);
|
||||||
if (vm.Deleted) await LoadAsync();
|
if (vm.Deleted) await LoadAsync();
|
||||||
else await RefreshRowAsync(item.Id);
|
else await RefreshRowAsync(item.Id);
|
||||||
@@ -409,6 +398,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
row.Name = entity.Name;
|
row.Name = entity.Name;
|
||||||
row.WorkingDir = entity.WorkingDir;
|
row.WorkingDir = entity.WorkingDir;
|
||||||
row.DefaultCommitType = entity.DefaultCommitType;
|
row.DefaultCommitType = entity.DefaultCommitType;
|
||||||
|
row.IsManual = entity.IsManual;
|
||||||
}
|
}
|
||||||
catch { /* best-effort refresh */ }
|
catch { /* best-effort refresh */ }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,10 +35,20 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
[ObservableProperty] private bool _parentInView = true;
|
[ObservableProperty] private bool _parentInView = true;
|
||||||
[ObservableProperty] private int _roadblockCount;
|
[ObservableProperty] private int _roadblockCount;
|
||||||
[ObservableProperty] private bool _isRefining;
|
[ObservableProperty] private bool _isRefining;
|
||||||
|
// 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.
|
||||||
|
[ObservableProperty] private bool _isManual;
|
||||||
|
// 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
|
||||||
|
// typically Idle+Active-worktree, i.e. would otherwise read "Parked").
|
||||||
|
[ObservableProperty] 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;
|
||||||
|
|
||||||
public bool CanRefine => Status == TaskStatus.Idle && PlanningPhase == PlanningPhase.None && !IsRefining;
|
public bool CanRefine => Status == TaskStatus.Idle && PlanningPhase == PlanningPhase.None
|
||||||
|
&& !IsRefining && !IsManual;
|
||||||
|
|
||||||
|
public string? ManualBadge => IsManual ? Loc.T("tasks.badgeManual") : null;
|
||||||
|
|
||||||
public DateTime CreatedAt { get; init; }
|
public DateTime CreatedAt { get; init; }
|
||||||
public string CreatedAtFormatted => CreatedAt == default ? "—" : Loc.T("vm.taskRow.createdPrefix", CreatedAt.ToString("MMM d"));
|
public string CreatedAtFormatted => CreatedAt == default ? "—" : Loc.T("vm.taskRow.createdPrefix", CreatedAt.ToString("MMM d"));
|
||||||
@@ -60,7 +70,8 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
|
|
||||||
public bool CanOpenPlanningSession => Status == TaskStatus.Idle
|
public bool CanOpenPlanningSession => Status == TaskStatus.Idle
|
||||||
&& PlanningPhase == PlanningPhase.None
|
&& PlanningPhase == PlanningPhase.None
|
||||||
&& !IsChild;
|
&& !IsChild
|
||||||
|
&& !IsManual;
|
||||||
public bool CanResumeOrDiscardPlanning => PlanningPhase == PlanningPhase.Active;
|
public bool CanResumeOrDiscardPlanning => PlanningPhase == PlanningPhase.Active;
|
||||||
|
|
||||||
// Pick up in a terminal only where a session + worktree reliably still exist: a task
|
// Pick up in a terminal only where a session + worktree reliably still exist: a task
|
||||||
@@ -84,8 +95,10 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
public bool IsOverdue => ScheduledFor is { } d && d.Date < DateTime.Today && !Done;
|
public bool IsOverdue => ScheduledFor is { } d && d.Date < DateTime.Today && !Done;
|
||||||
public bool IsRunning => Status == TaskStatus.Running;
|
public bool IsRunning => Status == TaskStatus.Running;
|
||||||
public bool IsWaitingForReview => Status == TaskStatus.WaitingForReview;
|
public bool IsWaitingForReview => Status == TaskStatus.WaitingForReview;
|
||||||
// Parked = set aside from review: Idle but still holding its Active worktree (vs a plain Idle task).
|
// Parked = set aside from review: Idle but still holding its Active worktree (vs a plain Idle
|
||||||
public bool IsParked => Status == TaskStatus.Idle && WorktreeState == ClaudeDo.Data.Models.WorktreeState.Active;
|
// task). A live ConPTY session is that same shape but not parked — it reads "Interactive".
|
||||||
|
public bool IsParked => Status == TaskStatus.Idle && WorktreeState == ClaudeDo.Data.Models.WorktreeState.Active
|
||||||
|
&& !HasInteractiveSession;
|
||||||
public bool IsQueued => Status == TaskStatus.Queued && string.IsNullOrEmpty(BlockedByTaskId);
|
public bool IsQueued => Status == TaskStatus.Queued && string.IsNullOrEmpty(BlockedByTaskId);
|
||||||
public bool IsWaiting => Status == TaskStatus.Queued && !string.IsNullOrEmpty(BlockedByTaskId);
|
public bool IsWaiting => Status == TaskStatus.Queued && !string.IsNullOrEmpty(BlockedByTaskId);
|
||||||
public bool CanRemoveFromQueue => IsQueued || HasQueuedSubtasks;
|
public bool CanRemoveFromQueue => IsQueued || HasQueuedSubtasks;
|
||||||
@@ -94,7 +107,8 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
// it must be finalized first.
|
// it must be finalized first.
|
||||||
public bool CanSendToQueue => !IsRunning && !IsQueued && !IsWaitingForReview && !HasQueuedSubtasks
|
public bool CanSendToQueue => !IsRunning && !IsQueued && !IsWaitingForReview && !HasQueuedSubtasks
|
||||||
&& (!IsChild || ParentFinalized)
|
&& (!IsChild || ParentFinalized)
|
||||||
&& PlanningPhase != PlanningPhase.Active;
|
&& PlanningPhase != PlanningPhase.Active
|
||||||
|
&& !IsManual;
|
||||||
// Parent-level "send plan to queue" — only once the plan is finalized (children Planned).
|
// Parent-level "send plan to queue" — only once the plan is finalized (children Planned).
|
||||||
// Drives the routing inside SendToQueue, not a separate menu entry.
|
// Drives the routing inside SendToQueue, not a separate menu entry.
|
||||||
public bool CanQueuePlan => !IsChild && HasPlanningChildren
|
public bool CanQueuePlan => !IsChild && HasPlanningChildren
|
||||||
@@ -115,7 +129,14 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
public string DiffDeletionsText => $"−{DiffDeletions}";
|
public string DiffDeletionsText => $"−{DiffDeletions}";
|
||||||
public string StepsText => Loc.T("vm.taskRow.stepsText", StepsCompleted, StepsCount);
|
public string StepsText => Loc.T("vm.taskRow.stepsText", StepsCompleted, StepsCount);
|
||||||
|
|
||||||
public string StatusLabel => IsParked ? Loc.T("vm.taskStatus.parked") : Status switch
|
// Null on a plain status chip so no tooltip shows there — the chip is only actionable
|
||||||
|
// while a session is open.
|
||||||
|
public string? InteractiveChipTooltip
|
||||||
|
=> HasInteractiveSession ? Loc.T("tasks.interactiveChipTip") : null;
|
||||||
|
|
||||||
|
public string StatusLabel
|
||||||
|
=> HasInteractiveSession ? Loc.T("vm.taskStatus.interactive")
|
||||||
|
: IsParked ? Loc.T("vm.taskStatus.parked") : Status switch
|
||||||
{
|
{
|
||||||
TaskStatus.Idle => Loc.T("vm.taskStatus.idle"),
|
TaskStatus.Idle => Loc.T("vm.taskStatus.idle"),
|
||||||
TaskStatus.Queued => Loc.T("vm.taskStatus.queued"),
|
TaskStatus.Queued => Loc.T("vm.taskStatus.queued"),
|
||||||
@@ -130,10 +151,14 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
|
|
||||||
// A planning parent that is still Idle (Active planning, or Finalized-but-not-yet-queued)
|
// A planning parent that is still Idle (Active planning, or Finalized-but-not-yet-queued)
|
||||||
// already shows its state via the PLANNING/PLANNED badge next to the title. The lifecycle
|
// already shows its state via the PLANNING/PLANNED badge next to the title. The lifecycle
|
||||||
// chip would read "Idle" (or "Parked"), which is misleading, so hide it in that case.
|
// chip would read "Idle" (or "Parked"), which is misleading, so hide it in that case —
|
||||||
public bool ShowStatusChip => !(PlanningPhase != PlanningPhase.None && Status == TaskStatus.Idle);
|
// unless there's a live session, where "Interactive" is exactly what the user needs to see.
|
||||||
|
public bool ShowStatusChip => HasInteractiveSession
|
||||||
|
|| !(PlanningPhase != PlanningPhase.None && Status == TaskStatus.Idle);
|
||||||
|
|
||||||
public string StatusChipClass => (Status, IsBlocked: !string.IsNullOrEmpty(BlockedByTaskId)) switch
|
public string StatusChipClass => HasInteractiveSession
|
||||||
|
? "interactive"
|
||||||
|
: (Status, IsBlocked: !string.IsNullOrEmpty(BlockedByTaskId)) switch
|
||||||
{
|
{
|
||||||
(TaskStatus.Running, _) => "running",
|
(TaskStatus.Running, _) => "running",
|
||||||
(TaskStatus.WaitingForReview, _) => "review",
|
(TaskStatus.WaitingForReview, _) => "review",
|
||||||
@@ -208,6 +233,23 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
|
|
||||||
partial void OnIsRefiningChanged(bool value) => OnPropertyChanged(nameof(CanRefine));
|
partial void OnIsRefiningChanged(bool value) => OnPropertyChanged(nameof(CanRefine));
|
||||||
|
|
||||||
|
partial void OnIsManualChanged(bool value)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(ManualBadge));
|
||||||
|
OnPropertyChanged(nameof(CanRefine));
|
||||||
|
OnPropertyChanged(nameof(CanSendToQueue));
|
||||||
|
OnPropertyChanged(nameof(CanOpenPlanningSession));
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnHasInteractiveSessionChanged(bool value)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(IsParked));
|
||||||
|
OnPropertyChanged(nameof(StatusChipClass));
|
||||||
|
OnPropertyChanged(nameof(StatusLabel));
|
||||||
|
OnPropertyChanged(nameof(ShowStatusChip));
|
||||||
|
OnPropertyChanged(nameof(InteractiveChipTooltip));
|
||||||
|
}
|
||||||
|
|
||||||
partial void OnHasQueuedSubtasksChanged(bool value)
|
partial void OnHasQueuedSubtasksChanged(bool value)
|
||||||
{
|
{
|
||||||
OnPropertyChanged(nameof(CanRemoveFromQueue));
|
OnPropertyChanged(nameof(CanRemoveFromQueue));
|
||||||
@@ -272,6 +314,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
Done = t.Status == TaskStatus.Done;
|
Done = t.Status == TaskStatus.Done;
|
||||||
IsStarred = t.IsStarred;
|
IsStarred = t.IsStarred;
|
||||||
IsMyDay = t.IsMyDay;
|
IsMyDay = t.IsMyDay;
|
||||||
|
IsManual = t.IsManual;
|
||||||
Status = t.Status;
|
Status = t.Status;
|
||||||
PlanningPhase = t.PlanningPhase;
|
PlanningPhase = t.PlanningPhase;
|
||||||
Branch = t.Worktree?.BranchName;
|
Branch = t.Worktree?.BranchName;
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
private readonly Dictionary<string, bool> _expandedState = new();
|
private readonly Dictionary<string, bool> _expandedState = new();
|
||||||
private ListNavItemViewModel? _currentList;
|
private ListNavItemViewModel? _currentList;
|
||||||
private CancellationTokenSource? _loadCts;
|
private CancellationTokenSource? _loadCts;
|
||||||
|
// Task ids with a live ConPTY pane in Mission Control; kept here so rows loaded later still
|
||||||
|
// pick the flag up (see SyncInteractiveSessions).
|
||||||
|
private readonly HashSet<string> _interactiveSessionIds = new();
|
||||||
private static readonly TaskListFilterRegistry _filters = new();
|
private static readonly TaskListFilterRegistry _filters = new();
|
||||||
|
|
||||||
public event EventHandler? SelectionChanged;
|
public event EventHandler? SelectionChanged;
|
||||||
@@ -274,6 +277,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
{
|
{
|
||||||
var row = TaskRowViewModel.FromEntity(t);
|
var row = TaskRowViewModel.FromEntity(t);
|
||||||
row.ShowListChip = showListChip;
|
row.ShowListChip = showListChip;
|
||||||
|
row.HasInteractiveSession = _interactiveSessionIds.Contains(row.Id);
|
||||||
Items.Add(row);
|
Items.Add(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -423,6 +427,8 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
if (string.IsNullOrWhiteSpace(NewTaskTitle) || _currentList?.Kind != ListKind.User) return;
|
if (string.IsNullOrWhiteSpace(NewTaskTitle) || _currentList?.Kind != ListKind.User) return;
|
||||||
var listId = _currentList.Id["user:".Length..];
|
var listId = _currentList.Id["user:".Length..];
|
||||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
// A manual list holds reminders, so tasks created in it start out manual.
|
||||||
|
var listIsManual = await db.Lists.Where(l => l.Id == listId).Select(l => l.IsManual).FirstOrDefaultAsync();
|
||||||
var entity = new TaskEntity
|
var entity = new TaskEntity
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid().ToString("N"),
|
Id = Guid.NewGuid().ToString("N"),
|
||||||
@@ -430,6 +436,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
Title = NewTaskTitle.Trim(),
|
Title = NewTaskTitle.Trim(),
|
||||||
Status = TaskStatus.Idle,
|
Status = TaskStatus.Idle,
|
||||||
CreatedAt = DateTime.UtcNow,
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
IsManual = listIsManual,
|
||||||
};
|
};
|
||||||
await new TaskRepository(db).AddAsync(entity);
|
await new TaskRepository(db).AddAsync(entity);
|
||||||
var row = TaskRowViewModel.FromEntity(entity);
|
var row = TaskRowViewModel.FromEntity(entity);
|
||||||
@@ -441,6 +448,17 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
TasksChanged?.Invoke(this, EventArgs.Empty);
|
TasksChanged?.Invoke(this, EventArgs.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Replaces the set of tasks that currently have an open interactive (ConPTY) session,
|
||||||
|
/// so their lifecycle chip reads "Interactive" instead of "Parked".</summary>
|
||||||
|
public void SyncInteractiveSessions(IEnumerable<string> taskIds)
|
||||||
|
{
|
||||||
|
_interactiveSessionIds.Clear();
|
||||||
|
foreach (var id in taskIds)
|
||||||
|
_interactiveSessionIds.Add(id);
|
||||||
|
foreach (var r in Items)
|
||||||
|
r.HasInteractiveSession = _interactiveSessionIds.Contains(r.Id);
|
||||||
|
}
|
||||||
|
|
||||||
public bool CanReorder => _currentList?.Kind == ListKind.User;
|
public bool CanReorder => _currentList?.Kind == ListKind.User;
|
||||||
|
|
||||||
public void ClearDropHints()
|
public void ClearDropHints()
|
||||||
@@ -607,6 +625,23 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
TasksChanged?.Invoke(this, EventArgs.Empty);
|
TasksChanged?.Invoke(this, EventArgs.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Flips a task between "Claude can run this" and "manual reminder". A manual task
|
||||||
|
/// hides every hand-off affordance and is skipped by the queue picker and daily prep.</summary>
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task ToggleManualAsync(TaskRowViewModel? row)
|
||||||
|
{
|
||||||
|
if (row is null) return;
|
||||||
|
row.IsManual = !row.IsManual;
|
||||||
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id);
|
||||||
|
if (entity != null)
|
||||||
|
{
|
||||||
|
entity.IsManual = row.IsManual;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
TasksChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task AddToMyDayAsync(TaskRowViewModel? row)
|
private async Task AddToMyDayAsync(TaskRowViewModel? row)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -215,6 +215,8 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
|||||||
MissionControl.ShowDetached = (monitor, reDock) => Dialogs?.ShowDetachedMonitor(monitor, reDock);
|
MissionControl.ShowDetached = (monitor, reDock) => Dialogs?.ShowDetachedMonitor(monitor, reDock);
|
||||||
MissionControl.OpenSettingsRequested = () => Lists.OpenSettingsCommand.Execute(null);
|
MissionControl.OpenSettingsRequested = () => Lists.OpenSettingsCommand.Execute(null);
|
||||||
MissionControl.ErrorReported += FlashFooterError;
|
MissionControl.ErrorReported += FlashFooterError;
|
||||||
|
// Keep the task rows' "Interactive" chip in step with Mission Control's open ConPTY panes.
|
||||||
|
MissionControl.ConPtySessions.CollectionChanged += (_, _) => SyncInteractiveSessionChips();
|
||||||
_updateCheck = updateCheck;
|
_updateCheck = updateCheck;
|
||||||
_installerLocator = installerLocator;
|
_installerLocator = installerLocator;
|
||||||
_workerLocator = workerLocator;
|
_workerLocator = workerLocator;
|
||||||
@@ -330,6 +332,16 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
|||||||
Dialogs.ShowMissionControl(MissionControl);
|
Dialogs.ShowMissionControl(MissionControl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void SyncInteractiveSessionChips()
|
||||||
|
{
|
||||||
|
if (MissionControl is null) return;
|
||||||
|
Tasks.SyncInteractiveSessions(
|
||||||
|
MissionControl.ConPtySessions
|
||||||
|
.Select(s => s.TaskId)
|
||||||
|
.Where(id => !string.IsNullOrEmpty(id))
|
||||||
|
.Select(id => id!));
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task OpenAbout()
|
private async Task OpenAbout()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -35,18 +35,44 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IMissionControl
|
|||||||
public event Action<string>? SubmitForReviewRequested;
|
public event Action<string>? SubmitForReviewRequested;
|
||||||
|
|
||||||
/// <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.</summary>
|
/// (no task, never deduped); prefer <see cref="CreateAdHoc"/> at ad-hoc call sites.
|
||||||
public ConPtyPaneViewModel(string? taskId, string displayTitle, TerminalLaunchDescriptor descriptor)
|
/// <paramref name="descriptorFactory"/> is resolved by the pane itself (in <see cref="Start"/>)
|
||||||
|
/// so the tile — and its starting spinner — is visible while the launch spec is still being
|
||||||
|
/// fetched. The host must wire its handlers and then call <see cref="Start"/>.</summary>
|
||||||
|
public ConPtyPaneViewModel(
|
||||||
|
string? taskId,
|
||||||
|
string displayTitle,
|
||||||
|
Func<System.Threading.Tasks.Task<TerminalLaunchDescriptor>> descriptorFactory)
|
||||||
{
|
{
|
||||||
TaskId = taskId;
|
TaskId = taskId;
|
||||||
_displayTitle = displayTitle;
|
_displayTitle = displayTitle;
|
||||||
|
_descriptorFactory = descriptorFactory;
|
||||||
Terminal.PropertyChanged += OnTerminalPropertyChanged;
|
Terminal.PropertyChanged += OnTerminalPropertyChanged;
|
||||||
Terminal.Start(descriptor);
|
}
|
||||||
|
|
||||||
|
private readonly Func<System.Threading.Tasks.Task<TerminalLaunchDescriptor>> _descriptorFactory;
|
||||||
|
|
||||||
|
/// <summary>Resolves the launch spec and spawns the session. Call after wiring
|
||||||
|
/// <see cref="ErrorReported"/> so a failed launch is not swallowed.</summary>
|
||||||
|
public void Start() => _ = StartAsync();
|
||||||
|
|
||||||
|
private async System.Threading.Tasks.Task StartAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Terminal.Start(await _descriptorFactory());
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Terminal.ReportStartFailure(ex.Message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Ad-hoc pane — no task, no dedup.</summary>
|
/// <summary>Ad-hoc pane — no task, no dedup.</summary>
|
||||||
public static ConPtyPaneViewModel CreateAdHoc(string displayTitle, TerminalLaunchDescriptor descriptor)
|
public static ConPtyPaneViewModel CreateAdHoc(
|
||||||
=> new(null, displayTitle, descriptor);
|
string displayTitle,
|
||||||
|
Func<System.Threading.Tasks.Task<TerminalLaunchDescriptor>> descriptorFactory)
|
||||||
|
=> new(null, displayTitle, descriptorFactory);
|
||||||
|
|
||||||
private void OnTerminalPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
private void OnTerminalPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -241,20 +241,8 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
|||||||
}
|
}
|
||||||
catch { /* best-effort title lookup */ }
|
catch { /* best-effort title lookup */ }
|
||||||
|
|
||||||
try
|
AddConPtyPane(new ConPtyPaneViewModel(taskId, title,
|
||||||
{
|
() => DescribeAsync(() => _worker.GetInteractiveLaunchSpecAsync(taskId))));
|
||||||
var spec = await _worker.GetInteractiveLaunchSpecAsync(taskId);
|
|
||||||
var descriptor = new TerminalLaunchDescriptor(spec.Cwd, spec.Exe, spec.Args, spec.Env);
|
|
||||||
var pane = new ConPtyPaneViewModel(taskId, title, descriptor);
|
|
||||||
pane.ErrorReported += OnConPtyPaneError;
|
|
||||||
pane.CloseRequested += CloseConPtySession;
|
|
||||||
pane.SubmitForReviewRequested += OnPaneSubmitForReview;
|
|
||||||
ConPtySessions.Add(pane);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
ErrorReported?.Invoke(Loc.T("missionControl.conptyLaunchFailed", ex.Message));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Starts (or resumes) a planning session and hosts it as an embedded ConPTY Command Center
|
// Starts (or resumes) a planning session and hosts it as an embedded ConPTY Command Center
|
||||||
@@ -279,58 +267,32 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
|||||||
catch { /* best-effort title lookup */ }
|
catch { /* best-effort title lookup */ }
|
||||||
title += Loc.T("missionControl.planningTitleSuffix");
|
title += Loc.T("missionControl.planningTitleSuffix");
|
||||||
|
|
||||||
try
|
AddConPtyPane(new ConPtyPaneViewModel(taskId, title, () => DescribeAsync(() => resume
|
||||||
{
|
? _worker.GetPlanningResumeLaunchSpecAsync(taskId)
|
||||||
var spec = resume
|
: _worker.GetPlanningStartLaunchSpecAsync(taskId))));
|
||||||
? await _worker.GetPlanningResumeLaunchSpecAsync(taskId)
|
|
||||||
: await _worker.GetPlanningStartLaunchSpecAsync(taskId);
|
|
||||||
var descriptor = new TerminalLaunchDescriptor(spec.Cwd, spec.Exe, spec.Args, spec.Env);
|
|
||||||
var pane = new ConPtyPaneViewModel(taskId, title, descriptor);
|
|
||||||
pane.ErrorReported += OnConPtyPaneError;
|
|
||||||
pane.CloseRequested += CloseConPtySession;
|
|
||||||
pane.SubmitForReviewRequested += OnPaneSubmitForReview;
|
|
||||||
ConPtySessions.Add(pane);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
ErrorReported?.Invoke(Loc.T("missionControl.conptyLaunchFailed", ex.Message));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ad-hoc (task-less) ConPTY session in a user-chosen directory. Never deduped — every call
|
// Ad-hoc (task-less) ConPTY session in a user-chosen directory. Never deduped — every call
|
||||||
// opens a fresh pane, unlike the task-based OpenConPtySessionAsync above.
|
// opens a fresh pane, unlike the task-based OpenConPtySessionAsync above.
|
||||||
public async System.Threading.Tasks.Task OpenAdHocConPtySessionAsync(string directory)
|
public System.Threading.Tasks.Task OpenAdHocConPtySessionAsync(string directory)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(directory)) return;
|
if (string.IsNullOrEmpty(directory)) return System.Threading.Tasks.Task.CompletedTask;
|
||||||
|
|
||||||
var title = Path.GetFileName(directory.TrimEnd('\\', '/'));
|
var title = Path.GetFileName(directory.TrimEnd('\\', '/'));
|
||||||
if (string.IsNullOrEmpty(title)) title = directory;
|
if (string.IsNullOrEmpty(title)) title = directory;
|
||||||
|
|
||||||
try
|
AddConPtyPane(ConPtyPaneViewModel.CreateAdHoc(title,
|
||||||
{
|
() => DescribeAsync(() => _worker.GetAdHocLaunchSpecAsync(directory))));
|
||||||
var spec = await _worker.GetAdHocLaunchSpecAsync(directory);
|
return System.Threading.Tasks.Task.CompletedTask;
|
||||||
var descriptor = new TerminalLaunchDescriptor(spec.Cwd, spec.Exe, spec.Args, spec.Env);
|
|
||||||
var pane = ConPtyPaneViewModel.CreateAdHoc(title, descriptor);
|
|
||||||
pane.ErrorReported += OnConPtyPaneError;
|
|
||||||
pane.CloseRequested += CloseConPtySession;
|
|
||||||
pane.SubmitForReviewRequested += OnPaneSubmitForReview;
|
|
||||||
ConPtySessions.Add(pane);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
ErrorReported?.Invoke(Loc.T("missionControl.conptyLaunchFailed", ex.Message));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge-helper session over a hand-picked set of tasks ("Let Claude handle it").
|
// List-handler session over a hand-picked set of tasks ("Let Claude handle it").
|
||||||
// Ad-hoc style: no owning task, never deduped — every run opens a fresh pane.
|
// Ad-hoc style: no owning task, never deduped — every run opens a fresh pane.
|
||||||
public async System.Threading.Tasks.Task OpenMergeHelperConPtySessionAsync(string? listId, IReadOnlyList<string> taskIds)
|
public async System.Threading.Tasks.Task OpenMergeHelperConPtySessionAsync(string listId, IReadOnlyList<string> taskIds)
|
||||||
{
|
{
|
||||||
if (taskIds is not { Count: > 0 }) return;
|
if (taskIds is not { Count: > 0 }) return;
|
||||||
|
|
||||||
var title = Loc.T("missionControl.mergeHelperTitle");
|
var title = Loc.T("missionControl.mergeHelperTitle");
|
||||||
if (listId is not null)
|
|
||||||
{
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||||
@@ -338,25 +300,31 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
|||||||
if (list?.Name is { Length: > 0 } name) title = $"{title} — {name}";
|
if (list?.Name is { Length: > 0 } name) title = $"{title} — {name}";
|
||||||
}
|
}
|
||||||
catch { /* best-effort title lookup */ }
|
catch { /* best-effort title lookup */ }
|
||||||
|
|
||||||
|
AddConPtyPane(ConPtyPaneViewModel.CreateAdHoc(title,
|
||||||
|
() => DescribeAsync(() => _worker.GetMergeHelperLaunchSpecAsync(taskIds, listId))));
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
// 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.
|
||||||
|
private void AddConPtyPane(ConPtyPaneViewModel pane)
|
||||||
{
|
{
|
||||||
var spec = await _worker.GetMergeHelperLaunchSpecAsync(taskIds, listId);
|
|
||||||
var descriptor = new TerminalLaunchDescriptor(spec.Cwd, spec.Exe, spec.Args, spec.Env);
|
|
||||||
var pane = ConPtyPaneViewModel.CreateAdHoc(title, descriptor);
|
|
||||||
pane.ErrorReported += OnConPtyPaneError;
|
pane.ErrorReported += OnConPtyPaneError;
|
||||||
pane.CloseRequested += CloseConPtySession;
|
pane.CloseRequested += CloseConPtySession;
|
||||||
pane.SubmitForReviewRequested += OnPaneSubmitForReview;
|
pane.SubmitForReviewRequested += OnPaneSubmitForReview;
|
||||||
ConPtySessions.Add(pane);
|
ConPtySessions.Add(pane);
|
||||||
}
|
pane.Start();
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
ErrorReported?.Invoke(Loc.T("missionControl.conptyLaunchFailed", ex.Message));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnConPtyPaneError(string message) => ErrorReported?.Invoke(message);
|
private static async System.Threading.Tasks.Task<TerminalLaunchDescriptor> DescribeAsync(
|
||||||
|
Func<System.Threading.Tasks.Task<LaunchSpec>> fetch)
|
||||||
|
{
|
||||||
|
var spec = await fetch();
|
||||||
|
return new TerminalLaunchDescriptor(spec.Cwd, spec.Exe, spec.Args, spec.Env);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnConPtyPaneError(string message)
|
||||||
|
=> ErrorReported?.Invoke(Loc.T("missionControl.conptyLaunchFailed", message));
|
||||||
|
|
||||||
// Submit a task's hand-driven ConPTY work for review, then close the pane (the interactive
|
// Submit a task's hand-driven ConPTY work for review, then close the pane (the interactive
|
||||||
// session is finished). The worker commits the worktree and moves the task to WaitingForReview.
|
// session is finished). The worker commits the worktree and moves the task to WaitingForReview.
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
|||||||
[ObservableProperty] private string _name = "";
|
[ObservableProperty] private string _name = "";
|
||||||
[ObservableProperty] private string _workingDir = "";
|
[ObservableProperty] private string _workingDir = "";
|
||||||
[ObservableProperty] private string _defaultCommitType = CommitTypeRegistry.DefaultType;
|
[ObservableProperty] private string _defaultCommitType = CommitTypeRegistry.DefaultType;
|
||||||
|
// A manual list holds reminders: tasks created here start out manual (TaskEntity.IsManual).
|
||||||
|
[ObservableProperty] private bool _isManual;
|
||||||
|
|
||||||
public ObservableCollection<string> CommitTypeOptions { get; } = new(CommitTypeRegistry.Types);
|
public ObservableCollection<string> CommitTypeOptions { get; } = new(CommitTypeRegistry.Types);
|
||||||
|
|
||||||
@@ -49,10 +51,12 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
|||||||
string name,
|
string name,
|
||||||
string? workingDir,
|
string? workingDir,
|
||||||
string defaultCommitType,
|
string defaultCommitType,
|
||||||
|
bool isManual = false,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
ListId = listId;
|
ListId = listId;
|
||||||
Name = name;
|
Name = name;
|
||||||
|
IsManual = isManual;
|
||||||
WorkingDir = workingDir ?? "";
|
WorkingDir = workingDir ?? "";
|
||||||
DefaultCommitType = string.IsNullOrWhiteSpace(defaultCommitType) ? CommitTypeRegistry.DefaultType : defaultCommitType;
|
DefaultCommitType = string.IsNullOrWhiteSpace(defaultCommitType) ? CommitTypeRegistry.DefaultType : defaultCommitType;
|
||||||
|
|
||||||
@@ -66,7 +70,8 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
|||||||
ListId,
|
ListId,
|
||||||
string.IsNullOrWhiteSpace(Name) ? Loc.T("vm.listSettings.untitled") : Name,
|
string.IsNullOrWhiteSpace(Name) ? Loc.T("vm.listSettings.untitled") : Name,
|
||||||
string.IsNullOrWhiteSpace(WorkingDir) ? null : WorkingDir,
|
string.IsNullOrWhiteSpace(WorkingDir) ? null : WorkingDir,
|
||||||
DefaultCommitType));
|
DefaultCommitType,
|
||||||
|
IsManual));
|
||||||
|
|
||||||
await Agent.SaveAsync();
|
await Agent.SaveAsync();
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ public sealed partial class MergeHelperTaskRowViewModel : ViewModelBase
|
|||||||
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 required string StatusText { get; init; }
|
public required string StatusText { get; init; }
|
||||||
public required string ListName { get; init; }
|
|
||||||
|
|
||||||
[ObservableProperty] private bool _isSelected;
|
[ObservableProperty] private bool _isSelected;
|
||||||
}
|
}
|
||||||
@@ -27,12 +26,11 @@ public sealed partial class MergeHelperTaskRowViewModel : ViewModelBase
|
|||||||
public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase
|
public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
private string? _listId;
|
private string _listId = "";
|
||||||
|
|
||||||
public ObservableCollection<MergeHelperTaskRowViewModel> Tasks { get; } = new();
|
public ObservableCollection<MergeHelperTaskRowViewModel> Tasks { get; } = new();
|
||||||
|
|
||||||
[ObservableProperty] private string _scopeLabel = "";
|
[ObservableProperty] private string _scopeLabel = "";
|
||||||
[ObservableProperty] private bool _isGlobal;
|
|
||||||
|
|
||||||
public bool HasTasks => Tasks.Count > 0;
|
public bool HasTasks => Tasks.Count > 0;
|
||||||
public bool CanConfirm => Tasks.Any(t => t.IsSelected);
|
public bool CanConfirm => Tasks.Any(t => t.IsSelected);
|
||||||
@@ -43,13 +41,10 @@ public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase
|
|||||||
public MergeHelperSelectionModalViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory)
|
public MergeHelperSelectionModalViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory)
|
||||||
=> _dbFactory = dbFactory;
|
=> _dbFactory = dbFactory;
|
||||||
|
|
||||||
public void Configure(string? listId, string? listName)
|
public void Configure(string listId, string listName)
|
||||||
{
|
{
|
||||||
_listId = listId;
|
_listId = listId;
|
||||||
IsGlobal = listId is null;
|
ScopeLabel = Loc.T("modals.mergeHelper.scopeList", listName);
|
||||||
ScopeLabel = listId is null
|
|
||||||
? Loc.T("modals.mergeHelper.scopeAll")
|
|
||||||
: Loc.T("modals.mergeHelper.scopeList", listName ?? "");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task LoadAsync(CancellationToken ct = default)
|
public async Task LoadAsync(CancellationToken ct = default)
|
||||||
@@ -58,14 +53,13 @@ public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase
|
|||||||
Tasks.Clear();
|
Tasks.Clear();
|
||||||
|
|
||||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||||
var query = ctx.Tasks.AsNoTracking()
|
var candidates = await ctx.Tasks.AsNoTracking()
|
||||||
.Where(t => t.Status != TaskStatus.Done && t.Status != TaskStatus.Cancelled);
|
.Where(t => t.Status != TaskStatus.Done && t.Status != TaskStatus.Cancelled)
|
||||||
if (_listId is not null)
|
.Where(t => t.ListId == _listId)
|
||||||
query = query.Where(t => t.ListId == _listId);
|
// Manual tasks are reminders the user owns — never offer them to the handler.
|
||||||
|
.Where(t => !t.IsManual)
|
||||||
var candidates = await query
|
|
||||||
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
|
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
|
||||||
.Select(t => new { t.Id, t.Title, t.Status, ListName = t.List.Name })
|
.Select(t => new { t.Id, t.Title, t.Status })
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
foreach (var c in candidates)
|
foreach (var c in candidates)
|
||||||
@@ -75,7 +69,6 @@ public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase
|
|||||||
Id = c.Id,
|
Id = c.Id,
|
||||||
Title = c.Title,
|
Title = c.Title,
|
||||||
StatusText = c.Status.ToString(),
|
StatusText = c.Status.ToString(),
|
||||||
ListName = c.ListName,
|
|
||||||
IsSelected = IsTickedByDefault(c.Status),
|
IsSelected = IsTickedByDefault(c.Status),
|
||||||
};
|
};
|
||||||
row.PropertyChanged += OnRowChanged;
|
row.PropertyChanged += OnRowChanged;
|
||||||
|
|||||||
@@ -27,6 +27,23 @@ public sealed partial class GeneralSettingsTabViewModel : ViewModelBase
|
|||||||
|
|
||||||
public ObservableCollection<SelectableSkillViewModel> SessionSkills { get; } = new();
|
public ObservableCollection<SelectableSkillViewModel> SessionSkills { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>One editable row per model alias: the effort and turn budget a run gets under that
|
||||||
|
/// model. Supplies the global defaults; list- and task-level max-turns overrides still win.</summary>
|
||||||
|
public ObservableCollection<ModelPresetRowViewModel> ModelPresets { get; } = new();
|
||||||
|
|
||||||
|
public void LoadModelPresets(IReadOnlyCollection<ModelPresetDto>? presets)
|
||||||
|
{
|
||||||
|
ModelPresets.Clear();
|
||||||
|
var source = presets is { Count: > 0 }
|
||||||
|
? presets.Select(p => new ModelPreset(p.Model, p.Effort, p.MaxTurns)).ToList()
|
||||||
|
: Data.Models.ModelPresets.Defaults.ToList();
|
||||||
|
foreach (var p in Data.Models.ModelPresets.Parse(Data.Models.ModelPresets.Serialize(source)))
|
||||||
|
ModelPresets.Add(new ModelPresetRowViewModel(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ModelPresetDto> ModelPresetDtos()
|
||||||
|
=> ModelPresets.Select(r => new ModelPresetDto(r.Model, r.Effort, (int)r.MaxTurns)).ToList();
|
||||||
|
|
||||||
public GeneralSettingsTabViewModel() { }
|
public GeneralSettingsTabViewModel() { }
|
||||||
|
|
||||||
public GeneralSettingsTabViewModel(ILocalizer localizer, Action<string> persist)
|
public GeneralSettingsTabViewModel(ILocalizer localizer, Action<string> persist)
|
||||||
@@ -50,10 +67,11 @@ public sealed partial class GeneralSettingsTabViewModel : ViewModelBase
|
|||||||
|
|
||||||
public string? Validate()
|
public string? Validate()
|
||||||
{
|
{
|
||||||
if (DefaultMaxTurns < 1 || DefaultMaxTurns > 200)
|
|
||||||
return "Max turns must be between 1 and 200.";
|
|
||||||
if (MaxParallelExecutions < 1 || MaxParallelExecutions > 20)
|
if (MaxParallelExecutions < 1 || MaxParallelExecutions > 20)
|
||||||
return "Max parallel executions must be between 1 and 20.";
|
return "Max parallel executions must be between 1 and 20.";
|
||||||
|
foreach (var row in ModelPresets)
|
||||||
|
if (row.MaxTurns is < 1 or > 200)
|
||||||
|
return $"Max turns for {row.Model} must be between 1 and 200.";
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,3 +91,21 @@ public sealed partial class GeneralSettingsTabViewModel : ViewModelBase
|
|||||||
return names.Count == 0 ? null : names;
|
return names.Count == 0 ? null : names;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>One row of the per-model preset table. Single consumer, so it lives here.</summary>
|
||||||
|
public sealed partial class ModelPresetRowViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
public string Model { get; }
|
||||||
|
public IReadOnlyList<string> EffortLevels { get; } = EffortRegistry.Levels;
|
||||||
|
|
||||||
|
[ObservableProperty] private string _effort;
|
||||||
|
// decimal so it binds straight to a NumericUpDown, like the other numeric settings.
|
||||||
|
[ObservableProperty] private decimal _maxTurns;
|
||||||
|
|
||||||
|
public ModelPresetRowViewModel(ModelPreset preset)
|
||||||
|
{
|
||||||
|
Model = preset.Model;
|
||||||
|
_effort = preset.Effort;
|
||||||
|
_maxTurns = preset.MaxTurns;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
|
|||||||
await OnlineInbox.LoadAsync();
|
await OnlineInbox.LoadAsync();
|
||||||
await SessionSkills.LoadAsync();
|
await SessionSkills.LoadAsync();
|
||||||
await General.LoadSessionSkillsAsync(_worker, dto?.SessionSkills);
|
await General.LoadSessionSkillsAsync(_worker, dto?.SessionSkills);
|
||||||
|
General.LoadModelPresets(dto?.ModelPresets);
|
||||||
}
|
}
|
||||||
finally { IsBusy = false; }
|
finally { IsBusy = false; }
|
||||||
}
|
}
|
||||||
@@ -102,7 +103,8 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
|
|||||||
.Split('\n').Select(l => l.Trim().TrimEnd('\r')).Where(l => l.Length > 0).ToList()),
|
.Split('\n').Select(l => l.Trim().TrimEnd('\r')).Where(l => l.Length > 0).ToList()),
|
||||||
General.StandupWeekday,
|
General.StandupWeekday,
|
||||||
Prime.DailyPrepMaxTasks,
|
Prime.DailyPrepMaxTasks,
|
||||||
General.SelectedSessionSkillNames());
|
General.SelectedSessionSkillNames(),
|
||||||
|
General.ModelPresetDtos());
|
||||||
await _worker.UpdateAppSettingsAsync(dto);
|
await _worker.UpdateAppSettingsAsync(dto);
|
||||||
await Prime.SaveAsync();
|
await Prime.SaveAsync();
|
||||||
await OnlineInbox.SaveAsync();
|
await OnlineInbox.SaveAsync();
|
||||||
|
|||||||
@@ -181,8 +181,8 @@
|
|||||||
</ItemsControl.ItemTemplate>
|
</ItemsControl.ItemTemplate>
|
||||||
</ItemsControl>
|
</ItemsControl>
|
||||||
|
|
||||||
<!-- New list + import + merge-helper row -->
|
<!-- New list + import row -->
|
||||||
<Grid ColumnDefinitions="*,Auto,Auto" Margin="0,4,0,0">
|
<Grid ColumnDefinitions="*,Auto" Margin="0,4,0,0">
|
||||||
<Button Grid.Column="0" Classes="new-list-btn"
|
<Button Grid.Column="0" Classes="new-list-btn"
|
||||||
Command="{Binding CreateListCommand}">
|
Command="{Binding CreateListCommand}">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
@@ -203,13 +203,6 @@
|
|||||||
Width="14" Height="14"
|
Width="14" Height="14"
|
||||||
Foreground="{DynamicResource TextMuteBrush}"/>
|
Foreground="{DynamicResource TextMuteBrush}"/>
|
||||||
</Button>
|
</Button>
|
||||||
<Button Grid.Column="2" Classes="icon-btn" Margin="6,0,0,0"
|
|
||||||
Command="{Binding LetClaudeHandleAllCommand}"
|
|
||||||
ToolTip.Tip="{loc:Tr lists.letClaudeAllTip}">
|
|
||||||
<PathIcon Data="{StaticResource Icon.Broom}"
|
|
||||||
Width="14" Height="14"
|
|
||||||
Foreground="{DynamicResource TextMuteBrush}"/>
|
|
||||||
</Button>
|
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|||||||
@@ -77,6 +77,13 @@
|
|||||||
<MenuItem Header="{loc:Tr tasks.ctxRemoveFromMyDay}"
|
<MenuItem Header="{loc:Tr tasks.ctxRemoveFromMyDay}"
|
||||||
IsVisible="{Binding IsMyDay}"
|
IsVisible="{Binding IsMyDay}"
|
||||||
Click="OnRemoveFromMyDayClick"/>
|
Click="OnRemoveFromMyDayClick"/>
|
||||||
|
<Separator/>
|
||||||
|
<MenuItem Header="{loc:Tr tasks.ctxMarkManual}"
|
||||||
|
IsVisible="{Binding !IsManual}"
|
||||||
|
Click="OnToggleManualClick"/>
|
||||||
|
<MenuItem Header="{loc:Tr tasks.ctxMarkClaudeTask}"
|
||||||
|
IsVisible="{Binding IsManual}"
|
||||||
|
Click="OnToggleManualClick"/>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
</Border.ContextMenu>
|
</Border.ContextMenu>
|
||||||
<Grid ColumnDefinitions="0,18,32,*,Auto,Auto,32" Margin="6,8,10,8">
|
<Grid ColumnDefinitions="0,18,32,*,Auto,Auto,32" Margin="6,8,10,8">
|
||||||
@@ -134,6 +141,10 @@
|
|||||||
IsVisible="{Binding IsPlanningParent}">
|
IsVisible="{Binding IsPlanningParent}">
|
||||||
<TextBlock Text="{Binding PlanningBadge}"/>
|
<TextBlock Text="{Binding PlanningBadge}"/>
|
||||||
</Border>
|
</Border>
|
||||||
|
<Border Classes="badge manual" IsVisible="{Binding IsManual}"
|
||||||
|
ToolTip.Tip="{loc:Tr tasks.manualTip}">
|
||||||
|
<TextBlock Text="{Binding ManualBadge}"/>
|
||||||
|
</Border>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
@@ -154,9 +165,13 @@
|
|||||||
IsVisible="{Binding IsAgentSuggested}"
|
IsVisible="{Binding IsAgentSuggested}"
|
||||||
ToolTip.Tip="{loc:Tr tasks.agentSuggestedTip}"/>
|
ToolTip.Tip="{loc:Tr tasks.agentSuggestedTip}"/>
|
||||||
|
|
||||||
<!-- Status chip -->
|
<!-- Status chip. When a ConPTY session is open it reads "Interactive" and is tappable —
|
||||||
|
it jumps to that pane in Mission Control. -->
|
||||||
<Border Classes="chip"
|
<Border Classes="chip"
|
||||||
IsVisible="{Binding ShowStatusChip}"
|
IsVisible="{Binding ShowStatusChip}"
|
||||||
|
Tapped="OnStatusChipTapped"
|
||||||
|
ToolTip.Tip="{Binding InteractiveChipTooltip}"
|
||||||
|
Classes.interactive="{Binding HasInteractiveSession}"
|
||||||
Classes.parked="{Binding IsParked}"
|
Classes.parked="{Binding IsParked}"
|
||||||
Classes.running="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Running}"
|
Classes.running="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Running}"
|
||||||
Classes.review="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=WaitingForReview}"
|
Classes.review="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=WaitingForReview}"
|
||||||
@@ -207,8 +222,9 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!-- Refine button -->
|
<!-- Refine button, replaced by a spinner while the refine run is in flight -->
|
||||||
<Button Grid.Column="5" Classes="icon-btn refine-btn"
|
<Panel Grid.Column="5">
|
||||||
|
<Button Classes="icon-btn refine-btn"
|
||||||
IsVisible="{Binding CanRefine}"
|
IsVisible="{Binding CanRefine}"
|
||||||
VerticalAlignment="Top" Margin="0,2,0,0"
|
VerticalAlignment="Top" Margin="0,2,0,0"
|
||||||
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).RefineTaskCommand}"
|
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).RefineTaskCommand}"
|
||||||
@@ -218,6 +234,11 @@
|
|||||||
<Path Classes="plan-icon" Data="{StaticResource Icon.Refine}"/>
|
<Path Classes="plan-icon" Data="{StaticResource Icon.Refine}"/>
|
||||||
</Viewbox>
|
</Viewbox>
|
||||||
</Button>
|
</Button>
|
||||||
|
<Ellipse Classes="spinner"
|
||||||
|
IsVisible="{Binding IsRefining}"
|
||||||
|
VerticalAlignment="Top" Margin="0,4,2,0"
|
||||||
|
ToolTip.Tip="{loc:Tr tasks.refiningTip}"/>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
<!-- Star toggle -->
|
<!-- Star toggle -->
|
||||||
<Button Grid.Column="6" Classes="icon-btn star-btn"
|
<Button Grid.Column="6" Classes="icon-btn star-btn"
|
||||||
|
|||||||
@@ -43,6 +43,12 @@ public partial class TaskRowView : UserControl
|
|||||||
await vm.ClearScheduleCommand.ExecuteAsync(row);
|
await vm.ClearScheduleCommand.ExecuteAsync(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async void OnToggleManualClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
|
||||||
|
await vm.ToggleManualCommand.ExecuteAsync(row);
|
||||||
|
}
|
||||||
|
|
||||||
private async void OnAddToMyDayClick(object? sender, RoutedEventArgs e)
|
private async void OnAddToMyDayClick(object? sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
|
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
|
||||||
@@ -67,6 +73,15 @@ public partial class TaskRowView : UserControl
|
|||||||
vm.OpenConPtySessionCommand.Execute(row);
|
vm.OpenConPtySessionCommand.Execute(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The status chip is only actionable while a ConPTY session is open; the open command dedupes
|
||||||
|
// by task id, so it surfaces Mission Control and focuses the existing pane.
|
||||||
|
private void OnStatusChipTapped(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is not TaskRowViewModel { HasInteractiveSession: true } row) return;
|
||||||
|
if (FindTasksVm() is { } vm)
|
||||||
|
vm.OpenConPtySessionCommand.Execute(row);
|
||||||
|
}
|
||||||
|
|
||||||
private async void OnPickUpInTerminalClick(object? sender, RoutedEventArgs e)
|
private async void OnPickUpInTerminalClick(object? sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
|
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
|
||||||
|
|||||||
@@ -18,8 +18,9 @@
|
|||||||
<converters:WorkerLogLevelToBrushConverter x:Key="WorkerLogLevelToBrush"/>
|
<converters:WorkerLogLevelToBrushConverter x:Key="WorkerLogLevelToBrush"/>
|
||||||
</Window.Resources>
|
</Window.Resources>
|
||||||
<Window.KeyBindings>
|
<Window.KeyBindings>
|
||||||
<KeyBinding Gesture="OemQuestion" Command="{Binding FocusSearchCommand}"/>
|
<!-- OemQuestion is the '#' key on a German layout: binding it here swallowed the
|
||||||
<KeyBinding Gesture="Shift+OemQuestion" Command="{Binding FocusSearchCommand}"/>
|
character app-wide. Ctrl+K is what the search box advertises anyway. -->
|
||||||
|
<KeyBinding Gesture="Ctrl+K" Command="{Binding FocusSearchCommand}"/>
|
||||||
<KeyBinding Gesture="Ctrl+N" Command="{Binding FocusAddTaskCommand}"/>
|
<KeyBinding Gesture="Ctrl+N" Command="{Binding FocusAddTaskCommand}"/>
|
||||||
</Window.KeyBindings>
|
</Window.KeyBindings>
|
||||||
<Grid x:Name="RootGrid" RowDefinitions="36,*,22">
|
<Grid x:Name="RootGrid" RowDefinitions="36,*,22">
|
||||||
|
|||||||
@@ -44,8 +44,20 @@
|
|||||||
TextWrapping="Wrap" />
|
TextWrapping="Wrap" />
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- Embedded ConPTY terminal -->
|
<!-- Embedded ConPTY terminal, with a starting overlay until the session is spawned -->
|
||||||
|
<Panel>
|
||||||
<views:InteractiveTerminalView DataContext="{Binding Terminal}" />
|
<views:InteractiveTerminalView DataContext="{Binding Terminal}" />
|
||||||
|
<Border IsVisible="{Binding Terminal.IsStarting}"
|
||||||
|
Background="{DynamicResource VoidBrush}">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="10"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||||
|
<Ellipse Classes="spinner"/>
|
||||||
|
<TextBlock Classes="meta" Text="{loc:Tr missionControl.conptyStarting}"
|
||||||
|
Foreground="{DynamicResource TextDimBrush}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|||||||
@@ -79,6 +79,7 @@
|
|||||||
<TextBlock Grid.Column="1" Classes="meta" Text="{Binding Name}"
|
<TextBlock Grid.Column="1" Classes="meta" Text="{Binding Name}"
|
||||||
VerticalAlignment="Center" TextTrimming="CharacterEllipsis"/>
|
VerticalAlignment="Center" TextTrimming="CharacterEllipsis"/>
|
||||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="4" VerticalAlignment="Center"
|
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="4" VerticalAlignment="Center"
|
||||||
|
Margin="0,0,8,0"
|
||||||
IsVisible="{Binding ShowStats}">
|
IsVisible="{Binding ShowStats}">
|
||||||
<TextBlock Foreground="{DynamicResource MossBrightBrush}" FontSize="{StaticResource FontSizeEyebrow}"
|
<TextBlock Foreground="{DynamicResource MossBrightBrush}" FontSize="{StaticResource FontSizeEyebrow}"
|
||||||
Text="{Binding Additions, StringFormat='+{0}'}"/>
|
Text="{Binding Additions, StringFormat='+{0}'}"/>
|
||||||
|
|||||||
@@ -60,6 +60,13 @@
|
|||||||
SelectedItem="{Binding DefaultCommitType, Mode=TwoWay}"
|
SelectedItem="{Binding DefaultCommitType, Mode=TwoWay}"
|
||||||
HorizontalAlignment="Left" MinWidth="160" />
|
HorizontalAlignment="Left" MinWidth="160" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Spacing="4">
|
||||||
|
<CheckBox IsChecked="{Binding IsManual, Mode=TwoWay}"
|
||||||
|
Content="{loc:Tr modals.listSettings.manualList}"/>
|
||||||
|
<TextBlock Text="{loc:Tr modals.listSettings.manualListHint}"
|
||||||
|
Opacity="0.6" FontSize="12" TextWrapping="Wrap"/>
|
||||||
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|||||||
@@ -38,12 +38,10 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<!-- Column headers -->
|
<!-- Column headers -->
|
||||||
<Grid DockPanel.Dock="Top" ColumnDefinitions="32,*,120,120" Margin="20,0,20,4"
|
<Grid DockPanel.Dock="Top" ColumnDefinitions="32,*,120" Margin="20,0,20,4"
|
||||||
IsVisible="{Binding HasTasks}">
|
IsVisible="{Binding HasTasks}">
|
||||||
<TextBlock Grid.Column="1" Classes="eyebrow" Text="{loc:Tr modals.mergeHelper.columnTask}"/>
|
<TextBlock Grid.Column="1" Classes="eyebrow" Text="{loc:Tr modals.mergeHelper.columnTask}"/>
|
||||||
<TextBlock Grid.Column="2" Classes="eyebrow" Text="{loc:Tr modals.mergeHelper.columnStatus}"/>
|
<TextBlock Grid.Column="2" Classes="eyebrow" Text="{loc:Tr modals.mergeHelper.columnStatus}"/>
|
||||||
<TextBlock Grid.Column="3" Classes="eyebrow" Text="{loc:Tr modals.mergeHelper.columnList}"
|
|
||||||
IsVisible="{Binding IsGlobal}"/>
|
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<ScrollViewer Padding="20,2,20,8">
|
<ScrollViewer Padding="20,2,20,8">
|
||||||
@@ -54,7 +52,7 @@
|
|||||||
<ItemsControl ItemsSource="{Binding Tasks}">
|
<ItemsControl ItemsSource="{Binding Tasks}">
|
||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate DataType="vm:MergeHelperTaskRowViewModel">
|
<DataTemplate DataType="vm:MergeHelperTaskRowViewModel">
|
||||||
<Grid ColumnDefinitions="32,*,120,120" Margin="0,1">
|
<Grid ColumnDefinitions="32,*,120" Margin="0,1">
|
||||||
<CheckBox Grid.Column="0" MinWidth="0"
|
<CheckBox Grid.Column="0" MinWidth="0"
|
||||||
IsChecked="{Binding IsSelected, Mode=TwoWay}"
|
IsChecked="{Binding IsSelected, Mode=TwoWay}"
|
||||||
VerticalAlignment="Center"/>
|
VerticalAlignment="Center"/>
|
||||||
@@ -65,10 +63,6 @@
|
|||||||
HorizontalAlignment="Left" VerticalAlignment="Center">
|
HorizontalAlignment="Left" VerticalAlignment="Center">
|
||||||
<TextBlock Text="{Binding StatusText}"/>
|
<TextBlock Text="{Binding StatusText}"/>
|
||||||
</Border>
|
</Border>
|
||||||
<TextBlock Classes="meta" Grid.Column="3" Text="{Binding ListName}"
|
|
||||||
VerticalAlignment="Center" Margin="8,0,0,0"
|
|
||||||
TextTrimming="CharacterEllipsis"
|
|
||||||
IsVisible="{Binding $parent[Window].((vm:MergeHelperSelectionModalViewModel)DataContext).IsGlobal}"/>
|
|
||||||
</Grid>
|
</Grid>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ItemsControl.ItemTemplate>
|
</ItemsControl.ItemTemplate>
|
||||||
|
|||||||
@@ -67,7 +67,7 @@
|
|||||||
PlaceholderText="{loc:Tr settings.general.defaultInstructionsPlaceholder}"
|
PlaceholderText="{loc:Tr settings.general.defaultInstructionsPlaceholder}"
|
||||||
Text="{Binding General.DefaultClaudeInstructions, Mode=TwoWay}"/>
|
Text="{Binding General.DefaultClaudeInstructions, Mode=TwoWay}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<Grid ColumnDefinitions="*,12,*,12,*">
|
<Grid ColumnDefinitions="*,12,*">
|
||||||
<StackPanel Grid.Column="0" Spacing="4">
|
<StackPanel Grid.Column="0" Spacing="4">
|
||||||
<TextBlock Classes="field-label" Text="{loc:Tr settings.general.model}"/>
|
<TextBlock Classes="field-label" Text="{loc:Tr settings.general.model}"/>
|
||||||
<ComboBox ItemsSource="{Binding General.Models}"
|
<ComboBox ItemsSource="{Binding General.Models}"
|
||||||
@@ -75,17 +75,38 @@
|
|||||||
HorizontalAlignment="Stretch"/>
|
HorizontalAlignment="Stretch"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<StackPanel Grid.Column="2" Spacing="4">
|
<StackPanel Grid.Column="2" Spacing="4">
|
||||||
<TextBlock Classes="field-label" Text="{loc:Tr settings.general.maxTurns}"/>
|
|
||||||
<NumericUpDown Value="{Binding General.DefaultMaxTurns, Mode=TwoWay}"
|
|
||||||
Minimum="1" Maximum="200" Increment="1" FormatString="0"/>
|
|
||||||
</StackPanel>
|
|
||||||
<StackPanel Grid.Column="4" Spacing="4">
|
|
||||||
<TextBlock Classes="field-label" Text="{loc:Tr settings.general.permission}"/>
|
<TextBlock Classes="field-label" Text="{loc:Tr settings.general.permission}"/>
|
||||||
<ComboBox ItemsSource="{Binding General.PermissionModes}"
|
<ComboBox ItemsSource="{Binding General.PermissionModes}"
|
||||||
SelectedItem="{Binding General.DefaultPermissionMode, Mode=TwoWay}"
|
SelectedItem="{Binding General.DefaultPermissionMode, Mode=TwoWay}"
|
||||||
HorizontalAlignment="Stretch"/>
|
HorizontalAlignment="Stretch"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Per-model run defaults: replaces the single global "max turns" field. -->
|
||||||
|
<StackPanel Spacing="4">
|
||||||
|
<TextBlock Classes="field-label" Text="{loc:Tr settings.general.modelPresets}"/>
|
||||||
|
<TextBlock Text="{loc:Tr settings.general.modelPresetsHint}"
|
||||||
|
Opacity="0.6" FontSize="12" TextWrapping="Wrap"/>
|
||||||
|
<Grid ColumnDefinitions="90,12,*,12,110" Margin="0,4,0,0">
|
||||||
|
<TextBlock Grid.Column="0" Classes="eyebrow" Text="{loc:Tr settings.general.model}"/>
|
||||||
|
<TextBlock Grid.Column="2" Classes="eyebrow" Text="{loc:Tr settings.general.effort}"/>
|
||||||
|
<TextBlock Grid.Column="4" Classes="eyebrow" Text="{loc:Tr settings.general.maxTurns}"/>
|
||||||
|
</Grid>
|
||||||
|
<ItemsControl ItemsSource="{Binding General.ModelPresets}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="settings:ModelPresetRowViewModel">
|
||||||
|
<Grid ColumnDefinitions="90,12,*,12,110" Margin="0,0,0,6">
|
||||||
|
<TextBlock Grid.Column="0" Text="{Binding Model}" VerticalAlignment="Center"/>
|
||||||
|
<ComboBox Grid.Column="2" ItemsSource="{Binding EffortLevels}"
|
||||||
|
SelectedItem="{Binding Effort, Mode=TwoWay}"
|
||||||
|
HorizontalAlignment="Stretch"/>
|
||||||
|
<NumericUpDown Grid.Column="4" Value="{Binding MaxTurns, Mode=TwoWay}"
|
||||||
|
Minimum="1" Maximum="200" Increment="1" FormatString="0"/>
|
||||||
|
</Grid>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
<StackPanel Spacing="4">
|
<StackPanel Spacing="4">
|
||||||
<TextBlock Classes="field-label" Text="{loc:Tr settings.general.maxParallelExecutions}"/>
|
<TextBlock Classes="field-label" Text="{loc:Tr settings.general.maxParallelExecutions}"/>
|
||||||
<NumericUpDown Value="{Binding General.MaxParallelExecutions, Mode=TwoWay}"
|
<NumericUpDown Value="{Binding General.MaxParallelExecutions, Mode=TwoWay}"
|
||||||
|
|||||||
@@ -29,11 +29,11 @@ Interfaces (e.g. `IQueueWaker`, `IPrimeClock`, `ITaskStateService`) live in an `
|
|||||||
|
|
||||||
- **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** — only component that writes `Status`, `PlanningPhase`, `BlockedByTaskId`. All transitions return a `TransitionResult` (no exceptions on invalid moves). Wakes the queue and broadcasts `TaskUpdated` automatically; advances the planning chain on child terminal transitions.
|
- **TaskStateService** — only component that writes `Status`, `PlanningPhase`, `BlockedByTaskId`. All transitions return a `TransitionResult` (no exceptions on invalid moves). Wakes the queue and broadcasts `TaskUpdated` automatically; advances the planning chain on child terminal transitions.
|
||||||
- **IQueueWaker / IQueuePicker / QueueService** — waker is a singleton `SemaphoreSlim`; picker performs the atomic `Queued → Running` claim filtered by `BlockedByTaskId IS NULL` and schedule; QueueService is a thin `BackgroundService` that loops on the waker and dispatches via `TaskRunner`.
|
- **IQueueWaker / IQueuePicker / QueueService** — waker is a singleton `SemaphoreSlim`; picker performs the atomic `Queued → Running` claim filtered by `BlockedByTaskId IS NULL`, `is_manual = 0` and schedule; QueueService is a thin `BackgroundService` that loops on the waker and dispatches via `TaskRunner`.
|
||||||
- **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 service; calls `TaskStateService.RecoverStaleRunningAsync` to flip orphaned `Running` rows to `Failed`.
|
- **StaleTaskRecovery** — startup-only service; calls `TaskStateService.RecoverStaleRunningAsync` to flip orphaned `Running` rows to `Failed`.
|
||||||
- **External/*** — always-on MCP tools for general Claude sessions, scoped to *starting* and *observing* sessions (no worktree/merge, multi-turn, planning, or app-settings writes). Auth via optional `X-ClaudeDo-Key` header. Registered explicitly in `Program.cs`'s external app via `.WithTools<T>()`. Every optional/filter parameter across these tools must carry a C# default value (e.g. `string? status = null`) — the MCP schema only marks a parameter optional when it has one; nullability alone doesn't do it (`ExternalMcpToolSchemaTests` guards this by reflection). `ExternalMcpExceptionFilter.Wrap` is registered as a call-tool filter so `InvalidOperationException`/`ArgumentException` messages survive as `McpException` — otherwise the SDK's own catch-all replaces any non-`McpException` with a generic "An error occurred invoking 'X'." Organized by concern:
|
- **External/*** — always-on MCP tools for general Claude sessions, scoped to *starting* and *observing* sessions (no worktree/merge, multi-turn, planning, or app-settings writes). Auth via optional `X-ClaudeDo-Key` header. Registered explicitly in `Program.cs`'s external app via `.WithTools<T>()`. Every optional/filter parameter across these tools must carry a C# default value (e.g. `string? status = null`) — the MCP schema only marks a parameter optional when it has one; nullability alone doesn't do it (`ExternalMcpToolSchemaTests` guards this by reflection). `ExternalMcpExceptionFilter.Wrap` is registered as a call-tool filter so `InvalidOperationException`/`ArgumentException` messages survive as `McpException` — otherwise the SDK's own catch-all replaces any non-`McpException` with a generic "An error occurred invoking 'X'." Organized by concern:
|
||||||
- `ExternalMcpService` — task CRUD + execution: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `AddSubtask`, `UpdateTask`, `UpdateTaskStatus` (`Idle` / `Queued`), `GetTaskStatusValues`, `ReviewTask` (`approve` / `reject_rerun` / `reject_park` / `cancel` for a WaitingForReview task; approve is review+merge exactly like the hub's `ApproveReview` — unit merge for parents, worktree merge into optional `targetBranch` for childless tasks, conflicts reported in the `ReviewTaskResult`), `RunTaskNow`, `ContinueTask`, `CancelTask`, `DeleteTask`; worktree/git: `GetTaskWorktree`, `GetTaskDiff`, `MergeTask`, `ListWorktrees`, `CleanupTaskWorktree`
|
- `ExternalMcpService` — task CRUD + execution: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `AddSubtask`, `UpdateTask`, `UpdateTaskStatus` (`Idle` / `Queued` / `Cancelled` — `Cancelled` goes through `TaskStateService.CancelAsync(..., allowFromIdle: true)`, the only caller that opts into cancelling from `Idle`; `PlanningChainCoordinator` relies on `Idle` staying a no-op there by default, since a child parked back to `Idle` mid-chain is a manual opt-out signal), `GetTaskStatusValues`, `ReviewTask` (`approve` / `reject_rerun` / `reject_park` / `cancel` for a WaitingForReview task; approve is review+merge exactly like the hub's `ApproveReview` — unit merge for parents, worktree merge into optional `targetBranch` for childless tasks, conflicts reported in the `ReviewTaskResult`), `RunTaskNow`, `ContinueTask`, `CancelTask`, `DeleteTask`; worktree/git: `GetTaskWorktree`, `GetTaskDiff`, `MergeTask`, `ListWorktrees`, `CleanupTaskWorktree`
|
||||||
- `BatchMcpTools` — best-effort batch variants that loop the `ExternalMcpService` single-entity methods (sequential — the scoped DbContext is not thread-safe; merge/review stay single-task): `BatchGetTasks`, `BatchAddTasks`, `BatchUpdateTaskStatus`, `BatchCancelTasks`, `BatchDeleteTasks`, `BatchSetMyDay`, `BatchCleanupTaskWorktrees`. Every tool returns a per-item result array ({ id/index, ok, error?, … }) — a failing item never aborts the rest — and rejects batches over 100 items.
|
- `BatchMcpTools` — best-effort batch variants that loop the `ExternalMcpService` single-entity methods (sequential — the scoped DbContext is not thread-safe; merge/review stay single-task): `BatchGetTasks`, `BatchAddTasks`, `BatchUpdateTaskStatus`, `BatchCancelTasks`, `BatchDeleteTasks`, `BatchSetMyDay`, `BatchCleanupTaskWorktrees`. Every tool returns a per-item result array ({ id/index, ok, error?, … }) — a failing item never aborts the rest — and rejects batches over 100 items.
|
||||||
- `ListMcpTools` — `CreateList`, `UpdateList`, `DeleteList`
|
- `ListMcpTools` — `CreateList`, `UpdateList`, `DeleteList`
|
||||||
- `ConfigMcpTools` — `GetListConfig`, `SetListConfig`, `GetTaskConfig`, `SetTaskConfig`
|
- `ConfigMcpTools` — `GetListConfig`, `SetListConfig`, `GetTaskConfig`, `SetTaskConfig`
|
||||||
@@ -67,12 +67,13 @@ Interfaces (e.g. `IQueueWaker`, `IPrimeClock`, `ITaskStateService`) live in an `
|
|||||||
| `Status` | `Idle`, `Queued`, `Running`, `WaitingForChildren`, `WaitingForReview`, `Done`, `Failed`, `Cancelled` | Lifecycle only. `WaitingForChildren` = parent's own work is done, waiting on its children. |
|
| `Status` | `Idle`, `Queued`, `Running`, `WaitingForChildren`, `WaitingForReview`, `Done`, `Failed`, `Cancelled` | Lifecycle only. `WaitingForChildren` = parent's own work is done, waiting on its 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 `BlockedByTaskId != NULL` is skipped by the picker. |
|
| `BlockedByTaskId` | nullable FK | Replaces legacy `Waiting`. A queued row with `BlockedByTaskId != NULL` is skipped by the picker. |
|
||||||
|
| `IsManual` | bool | Reminder only the user can do. `TaskStateService.EnqueueAsync`/`StartRunningAsync` refuse it, the queue picker skips it, and `GetDailyPrepCandidates` never offers it. An interactive ConPTY session is still allowed. |
|
||||||
| `ReviewFeedback` | nullable string | Reviewer's rejection comment. Set by `RejectToQueueAsync`; consumed and cleared by `QueueService` on the next re-run (resumes the Claude session with it as the next-turn prompt). |
|
| `ReviewFeedback` | nullable string | Reviewer's rejection comment. Set by `RejectToQueueAsync`; consumed and cleared by `QueueService` on the next re-run (resumes the Claude session with it as the next-turn prompt). |
|
||||||
|
|
||||||
Allowed transitions (enforced by `TaskStateService`):
|
Allowed transitions (enforced by `TaskStateService`):
|
||||||
|
|
||||||
```
|
```
|
||||||
Idle → Queued | Running (RunNow)
|
Idle → Queued | Running (RunNow) | Cancelled (external update_task_status only, allowFromIdle: true)
|
||||||
Queued → Running | Cancelled | Idle | Failed (OverrideSlotService preflight gap: RunAsync can fail before StartRunningAsync is called)
|
Queued → Running | Cancelled | Idle | Failed (OverrideSlotService preflight gap: RunAsync can fail before StartRunningAsync is called)
|
||||||
Running → WaitingForReview (standalone success, no children)
|
Running → WaitingForReview (standalone success, no children)
|
||||||
| WaitingForChildren (parent with pending children)
|
| WaitingForChildren (parent with pending children)
|
||||||
@@ -121,7 +122,7 @@ A child that hits a roadblock (fails, or reports `CLAUDEDO_BLOCKED` roadblocks)
|
|||||||
1. Load task + list metadata from DB; resolve config from `list_config` + task-level overrides (model, system_prompt, agent_path)
|
1. Load task + list metadata from DB; resolve config from `list_config` + task-level overrides (model, system_prompt, agent_path)
|
||||||
2. Create worktree (if `WorkingDir` set) or sandbox directory
|
2. Create worktree (if `WorkingDir` set) or sandbox directory
|
||||||
3. Mark task "running", broadcast `TaskStarted`
|
3. Mark task "running", broadcast `TaskStarted`
|
||||||
4. Build CLI args via `ClaudeArgsBuilder`; inject attachment absolute paths via `TaskPromptComposer.Compose` (appends a read-only "## Reference files" section); invoke `ClaudeProcess` with task prompt
|
4. Resolve the effective model (task → list → `AppSettings.DefaultModel`), then take its `ModelPresets` row: the preset supplies `--effort` and the **global** max-turns default (task/list `MaxTurns` overrides still win). Build CLI args via `ClaudeArgsBuilder`; inject attachment absolute paths via `TaskPromptComposer.Compose` (appends a read-only "## Reference files" section); invoke `ClaudeProcess` with task prompt
|
||||||
5. Stream NDJSON output through `StreamAnalyzer`; lines forwarded to log file and SignalR (`TaskMessage`)
|
5. Stream NDJSON output through `StreamAnalyzer`; lines forwarded to log file and SignalR (`TaskMessage`)
|
||||||
6. On success: auto-commit changes (worktree only), store run record, mark "done"
|
6. On success: auto-commit changes (worktree only), store run record, mark "done"
|
||||||
7. On failure: retry once if session ID available (`--resume`), then mark "failed"
|
7. On failure: retry once if session ID available (`--resume`), then mark "failed"
|
||||||
@@ -129,7 +130,7 @@ A child that hits a roadblock (fails, or reports `CLAUDEDO_BLOCKED` roadblocks)
|
|||||||
## Key Components
|
## Key Components
|
||||||
|
|
||||||
- **ClaudeProcess** — spawns `claude -p --output-format stream-json --verbose --permission-mode auto` (or whatever permission mode the app settings specify). Writes prompt to stdin, reads NDJSON from stdout. Supports CancellationToken (kills process tree).
|
- **ClaudeProcess** — spawns `claude -p --output-format stream-json --verbose --permission-mode auto` (or whatever permission mode the app settings specify). Writes prompt to stdin, reads NDJSON from stdout. Supports CancellationToken (kills process tree).
|
||||||
- **ClaudeArgsBuilder** — dynamically constructs CLI args; supports `--model`, `--append-system-prompt`, `--agents`, `--json-schema`, `--resume`
|
- **ClaudeArgsBuilder** — dynamically constructs CLI args; supports `--model`, `--effort`, `--max-turns`, `--append-system-prompt`, `--agents`, `--json-schema`, `--resume`
|
||||||
- **StreamAnalyzer** — parses rich NDJSON output; extracts session_id, token counts, turn counts, result text, structured output. Replaces MessageParser.
|
- **StreamAnalyzer** — parses rich NDJSON output; extracts session_id, token counts, turn counts, result text, structured output. Replaces MessageParser.
|
||||||
- **TaskResetService** — discards a failed task's worktree and resets the task row to Idle; preserves run history.
|
- **TaskResetService** — discards a failed task's worktree and resets the task row to Idle; preserves run history.
|
||||||
- **WorktreeManager** — creates worktrees at `claudedo/{taskId[:8]}` branches, commits changes with semantic messages, updates DB with head commit and diff stats
|
- **WorktreeManager** — creates worktrees at `claudedo/{taskId[:8]}` branches, commits changes with semantic messages, updates DB with head commit and diff stats
|
||||||
@@ -155,7 +156,7 @@ Each CLI invocation is recorded in the `task_runs` table via `TaskRunRepository`
|
|||||||
- Review/merge: `ApproveReview(taskId, targetBranch) -> MergeResultDto` (childless task: merges its worktree then Done, conflict stays WaitingForReview; task with children: drives `PlanningMergeOrchestrator` to merge the whole unit), `ContinuePlanningMerge` / `AbortPlanningMerge` (resolve a unit-merge conflict), `PreviewMerge(taskId, targetBranch) -> MergePreviewDto` (non-destructive mergeability check), `RejectReviewToQueue`, `RejectReviewToIdle`, `CancelReview`, `MergeTask`, `GetMergeTargets`
|
- Review/merge: `ApproveReview(taskId, targetBranch) -> MergeResultDto` (childless task: merges its worktree then Done, conflict stays WaitingForReview; task with children: drives `PlanningMergeOrchestrator` to merge the whole unit), `ContinuePlanningMerge` / `AbortPlanningMerge` (resolve a unit-merge conflict), `PreviewMerge(taskId, targetBranch) -> MergePreviewDto` (non-destructive mergeability check), `RejectReviewToQueue`, `RejectReviewToIdle`, `CancelReview`, `MergeTask`, `GetMergeTargets`
|
||||||
- Single-task conflict resolver (Layer C): `StartConflictMerge`, `GetMergeConflictDocuments` (segments), `WriteConflictResolution`, `ContinueConflictMerge`, `AbortConflictMerge` (service-level `TaskMergeService.ContinueMergeAsync`/`AbortMergeAsync` keep their names)
|
- Single-task conflict resolver (Layer C): `StartConflictMerge`, `GetMergeConflictDocuments` (segments), `WriteConflictResolution`, `ContinueConflictMerge`, `AbortConflictMerge` (service-level `TaskMergeService.ContinueMergeAsync`/`AbortMergeAsync` keep their names)
|
||||||
- Planning sessions: `StartPlanningSession`, `ResumePlanningSession`, `DiscardPlanningSession`, `FinalizePlanningSession`, `QueuePlanningSubtasks`, `GetPendingDraftCount`, `GetPlanningAggregate` (per-subtask diffs), `BuildPlanningIntegrationBranch` (combined diff)
|
- Planning sessions: `StartPlanningSession`, `ResumePlanningSession`, `DiscardPlanningSession`, `FinalizePlanningSession`, `QueuePlanningSubtasks`, `GetPendingDraftCount`, `GetPlanningAggregate` (per-subtask diffs), `BuildPlanningIntegrationBranch` (combined diff)
|
||||||
- Interactive sessions (embedded ConPTY, UI process): `ResumeTaskInTerminal` (pick-up-in-terminal), `GetInteractiveLaunchSpec`, `GetAdHocLaunchSpec`
|
- Interactive sessions (embedded ConPTY, UI process): `ResumeTaskInTerminal` (pick-up-in-terminal), `GetInteractiveLaunchSpec`, `GetAdHocLaunchSpec`. **Every** ConPTY spec that `InteractiveLaunchSpecService` builds leads with `--effort <level>` from the relevant model's preset (task/list model for a task session, `PlanningAlias` for planning, list config for the list handler, global default for ad-hoc). `--model` is deliberately NOT forced on an interactive session — the user can still switch models in the TUI.
|
||||||
- Worktrees: `CleanupFinishedWorktrees`, `ResetAllWorktrees`, `GetWorktreesOverview`, `SetWorktreeState`, `ForceRemoveWorktree`
|
- Worktrees: `CleanupFinishedWorktrees`, `ResetAllWorktrees`, `GetWorktreesOverview`, `SetWorktreeState`, `ForceRemoveWorktree`
|
||||||
- Agents/settings/lists: `GetAgents`, `RefreshAgents`, `RestoreDefaultAgents`, `GetAppSettings`, `UpdateAppSettings`, `UpdateList`, `UpdateListConfig`, `GetListConfig`, `UpdateTaskAgentSettings`
|
- Agents/settings/lists: `GetAgents`, `RefreshAgents`, `RestoreDefaultAgents`, `GetAppSettings`, `UpdateAppSettings`, `UpdateList`, `UpdateListConfig`, `GetListConfig`, `UpdateTaskAgentSettings`
|
||||||
- Reports/notes/prep: `GetWeekReport`, `GenerateWeekReport`, `GetDailyNotes`, `AddDailyNote`, `UpdateDailyNote`, `DeleteDailyNote`, `RunDailyPrepNow`, `ClearMyDay`, `GetLastPrepLog`, `ListPrimeSchedules`, `UpsertPrimeSchedule`, `DeletePrimeSchedule`
|
- Reports/notes/prep: `GetWeekReport`, `GenerateWeekReport`, `GetDailyNotes`, `AddDailyNote`, `UpdateDailyNote`, `DeleteDailyNote`, `RunDailyPrepNow`, `ClearMyDay`, `GetLastPrepLog`, `ListPrimeSchedules`, `UpsertPrimeSchedule`, `DeletePrimeSchedule`
|
||||||
|
|||||||
+15
-4
@@ -185,6 +185,8 @@ public sealed class ExternalMcpService
|
|||||||
CommitType = list.DefaultCommitType,
|
CommitType = list.DefaultCommitType,
|
||||||
CreatedBy = createdBy.NullIfBlank() ?? "mcp",
|
CreatedBy = createdBy.NullIfBlank() ?? "mcp",
|
||||||
Model = ModelRegistry.NormalizeAlias(model),
|
Model = ModelRegistry.NormalizeAlias(model),
|
||||||
|
// A manual list holds reminders, so anything filed there starts out manual.
|
||||||
|
IsManual = list.IsManual,
|
||||||
};
|
};
|
||||||
await _tasks.AddAsync(entity, cancellationToken);
|
await _tasks.AddAsync(entity, cancellationToken);
|
||||||
|
|
||||||
@@ -262,9 +264,10 @@ public sealed class ExternalMcpService
|
|||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool, Description(
|
[McpServerTool, Description(
|
||||||
"Update a task's status. Only 'Idle' and 'Queued' are permitted externally — " +
|
"Update a task's status. Only 'Idle', 'Queued' and 'Cancelled' are permitted externally — " +
|
||||||
"use run_task_now or cancel_task for execution control, and review_task to act on a WaitingForReview task. " +
|
"use run_task_now for execution control, and review_task to act on a WaitingForReview task. " +
|
||||||
"Settable: Idle (reset to editable), Queued (enqueue for execution). " +
|
"Settable: Idle (reset to editable), Queued (enqueue for execution), " +
|
||||||
|
"Cancelled (retire the task without deleting it; it can be reset to Idle later). " +
|
||||||
"Full lifecycle: Idle → Queued → Running → WaitingForReview → Done | Failed | Cancelled.")]
|
"Full lifecycle: Idle → Queued → Running → WaitingForReview → Done | Failed | Cancelled.")]
|
||||||
public async Task<TaskDto> UpdateTaskStatus(
|
public async Task<TaskDto> UpdateTaskStatus(
|
||||||
string taskId,
|
string taskId,
|
||||||
@@ -291,9 +294,15 @@ public sealed class ExternalMcpService
|
|||||||
throw new InvalidOperationException(enqueueResult.Reason ?? "Cannot enqueue task.");
|
throw new InvalidOperationException(enqueueResult.Reason ?? "Cannot enqueue task.");
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case TaskStatus.Cancelled:
|
||||||
|
var cancelResult = await _state.CancelAsync(taskId, DateTime.UtcNow, cancellationToken, allowFromIdle: true);
|
||||||
|
if (!cancelResult.Ok)
|
||||||
|
throw new InvalidOperationException(cancelResult.Reason ?? "Cannot cancel task.");
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
$"Status '{target}' is not settable externally. Use run_task_now or cancel_task.");
|
$"Status '{target}' is not settable externally. Use run_task_now or review_task.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
|
var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
|
||||||
@@ -781,6 +790,8 @@ public sealed class ExternalMcpService
|
|||||||
var candidates = idle
|
var candidates = idle
|
||||||
.Where(t => !t.IsMyDay
|
.Where(t => !t.IsMyDay
|
||||||
&& t.BlockedByTaskId == null
|
&& t.BlockedByTaskId == null
|
||||||
|
// A manual task is a reminder only the user can do — never a Claude candidate.
|
||||||
|
&& !t.IsManual
|
||||||
&& DailyPrepFilter.IsIncludedRepo(t.List?.WorkingDir, excludes))
|
&& DailyPrepFilter.IsIncludedRepo(t.List?.WorkingDir, excludes))
|
||||||
.OrderBy(t => t.CreatedAt)
|
.OrderBy(t => t.CreatedAt)
|
||||||
.Select(ToCandidate)
|
.Select(ToCandidate)
|
||||||
|
|||||||
@@ -40,7 +40,11 @@ public record AppSettingsDto(
|
|||||||
string? ReportExcludedPaths,
|
string? ReportExcludedPaths,
|
||||||
int StandupWeekday,
|
int StandupWeekday,
|
||||||
int DailyPrepMaxTasks,
|
int DailyPrepMaxTasks,
|
||||||
List<string>? SessionSkills = null);
|
List<string>? SessionSkills = null,
|
||||||
|
List<ModelPresetDto>? ModelPresets = null);
|
||||||
|
|
||||||
|
// Per-model run defaults (effort + turn budget) edited in Settings -> General.
|
||||||
|
public record ModelPresetDto(string Model, string Effort, int MaxTurns);
|
||||||
|
|
||||||
public record SessionSkillDto(
|
public record SessionSkillDto(
|
||||||
string Name,
|
string Name,
|
||||||
@@ -74,7 +78,7 @@ public record MergeTargetsDto(string DefaultBranch, IReadOnlyList<string> LocalB
|
|||||||
public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList<ConflictDocumentDto> Files);
|
public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList<ConflictDocumentDto> Files);
|
||||||
public record ConflictDocumentDto(string Path, bool IsBinary, IReadOnlyList<MergeSegmentDto> Segments);
|
public record ConflictDocumentDto(string Path, bool IsBinary, IReadOnlyList<MergeSegmentDto> Segments);
|
||||||
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);
|
public record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType, bool IsManual = false);
|
||||||
public record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
|
public record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
|
||||||
public record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
|
public record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
|
||||||
public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
|
public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
|
||||||
@@ -318,7 +322,9 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
row.ReportExcludedPaths,
|
row.ReportExcludedPaths,
|
||||||
row.StandupWeekday,
|
row.StandupWeekday,
|
||||||
row.DailyPrepMaxTasks,
|
row.DailyPrepMaxTasks,
|
||||||
SkillsFromJson(row.SessionSkills));
|
SkillsFromJson(row.SessionSkills),
|
||||||
|
Data.Models.ModelPresets.Parse(row.ModelPresets)
|
||||||
|
.Select(p => new ModelPresetDto(p.Model, p.Effort, p.MaxTurns)).ToList());
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task UpdateAppSettings(AppSettingsDto dto)
|
public async Task UpdateAppSettings(AppSettingsDto dto)
|
||||||
@@ -341,6 +347,11 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
StandupWeekday = dto.StandupWeekday is >= 0 and <= 6 ? dto.StandupWeekday : (int)DayOfWeek.Wednesday,
|
StandupWeekday = dto.StandupWeekday is >= 0 and <= 6 ? dto.StandupWeekday : (int)DayOfWeek.Wednesday,
|
||||||
DailyPrepMaxTasks = dto.DailyPrepMaxTasks,
|
DailyPrepMaxTasks = dto.DailyPrepMaxTasks,
|
||||||
SessionSkills = SkillsToJson(dto.SessionSkills),
|
SessionSkills = SkillsToJson(dto.SessionSkills),
|
||||||
|
// Normalized on the way in (unknown models dropped, effort validated, turns clamped).
|
||||||
|
ModelPresets = dto.ModelPresets is { Count: > 0 }
|
||||||
|
? Data.Models.ModelPresets.Serialize(
|
||||||
|
dto.ModelPresets.Select(p => new ModelPreset(p.Model, p.Effort, p.MaxTurns)))
|
||||||
|
: Data.Models.ModelPresets.SerializeDefaults(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -492,6 +503,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
entity.Name = dto.Name;
|
entity.Name = dto.Name;
|
||||||
entity.WorkingDir = string.IsNullOrWhiteSpace(dto.WorkingDir) ? null : dto.WorkingDir;
|
entity.WorkingDir = string.IsNullOrWhiteSpace(dto.WorkingDir) ? null : dto.WorkingDir;
|
||||||
entity.DefaultCommitType = string.IsNullOrWhiteSpace(dto.DefaultCommitType) ? CommitTypeRegistry.DefaultType : dto.DefaultCommitType;
|
entity.DefaultCommitType = string.IsNullOrWhiteSpace(dto.DefaultCommitType) ? CommitTypeRegistry.DefaultType : dto.DefaultCommitType;
|
||||||
|
entity.IsManual = dto.IsManual;
|
||||||
await repo.UpdateAsync(entity);
|
await repo.UpdateAsync(entity);
|
||||||
|
|
||||||
await _broadcaster.ListUpdated(dto.Id);
|
await _broadcaster.ListUpdated(dto.Id);
|
||||||
@@ -678,8 +690,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
|
|
||||||
// Builds the launch spec for an embedded ConPTY "merge helper" session that drives the given
|
// Builds the launch spec for an embedded ConPTY "merge helper" session that drives the given
|
||||||
// tasks to a merged/Done state via the mcp__claudedo__* tools. listId scopes the brief label
|
// tasks to a merged/Done state via the mcp__claudedo__* tools. listId scopes the brief label
|
||||||
// and cwd to that list; null means all lists.
|
// and cwd to that list.
|
||||||
public Task<LaunchSpec> GetMergeHelperLaunchSpec(string[] taskIds, string? listId) => HubGuard(() =>
|
public Task<LaunchSpec> GetMergeHelperLaunchSpec(string[] taskIds, string listId) => HubGuard(() =>
|
||||||
{
|
{
|
||||||
if (_interactiveLaunchSpec is null)
|
if (_interactiveLaunchSpec is null)
|
||||||
throw new InvalidOperationException("Interactive launch spec service is not configured.");
|
throw new InvalidOperationException("Interactive launch spec service is not configured.");
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ public sealed class QueuePicker : IQueuePicker
|
|||||||
{
|
{
|
||||||
// Atomic queue claim: UPDATE + RETURNING in a single statement prevents TOCTOU races.
|
// Atomic queue claim: UPDATE + RETURNING in a single statement prevents TOCTOU races.
|
||||||
// Raw SQL because EF cannot express UPDATE...RETURNING.
|
// Raw SQL because EF cannot express UPDATE...RETURNING.
|
||||||
// Eligible task must be Queued, unblocked, and due (or unscheduled).
|
// Eligible task must be Queued, unblocked, not manual, and due (or unscheduled).
|
||||||
// EF SQLite stores DateTime as "yyyy-MM-dd HH:mm:ss.fffffff" — same format used here for comparison.
|
// EF SQLite stores DateTime as "yyyy-MM-dd HH:mm:ss.fffffff" — same format used here for comparison.
|
||||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||||
var nowStr = now.ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss.fffffff");
|
var nowStr = now.ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss.fffffff");
|
||||||
@@ -27,6 +27,7 @@ public sealed class QueuePicker : IQueuePicker
|
|||||||
SELECT t.id FROM tasks t
|
SELECT t.id FROM tasks t
|
||||||
WHERE t.status = 'queued'
|
WHERE t.status = 'queued'
|
||||||
AND t.blocked_by_task_id IS NULL
|
AND t.blocked_by_task_id IS NULL
|
||||||
|
AND t.is_manual = 0
|
||||||
AND (t.scheduled_for IS NULL OR t.scheduled_for <= {0})
|
AND (t.scheduled_for IS NULL OR t.scheduled_for <= {0})
|
||||||
ORDER BY t.sort_order ASC, t.created_at ASC
|
ORDER BY t.sort_order ASC, t.created_at ASC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ public sealed record ClaudeRunConfig(
|
|||||||
string? PermissionMode = null,
|
string? PermissionMode = null,
|
||||||
string? McpConfigPath = null,
|
string? McpConfigPath = null,
|
||||||
string? AllowedTools = null,
|
string? AllowedTools = null,
|
||||||
IReadOnlyList<string>? SkillNames = null
|
IReadOnlyList<string>? SkillNames = null,
|
||||||
|
string? Effort = null
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
public IReadOnlyList<string> SkillNames { get; init; } = SkillNames ?? Array.Empty<string>();
|
public IReadOnlyList<string> SkillNames { get; init; } = SkillNames ?? Array.Empty<string>();
|
||||||
@@ -53,6 +54,12 @@ public sealed class ClaudeArgsBuilder
|
|||||||
args.Add(config.Model);
|
args.Add(config.Model);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(config.Effort))
|
||||||
|
{
|
||||||
|
args.Add("--effort");
|
||||||
|
args.Add(config.Effort);
|
||||||
|
}
|
||||||
|
|
||||||
if (config.MaxTurns is int turns && turns > 0)
|
if (config.MaxTurns is int turns && turns > 0)
|
||||||
{
|
{
|
||||||
args.Add("--max-turns");
|
args.Add("--max-turns");
|
||||||
|
|||||||
@@ -96,6 +96,11 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
|
|||||||
? WindowsTerminalLauncher.BuildResumeArgs(sessionId)
|
? WindowsTerminalLauncher.BuildResumeArgs(sessionId)
|
||||||
: BuildFreshPromptArgs(task);
|
: BuildFreshPromptArgs(task);
|
||||||
|
|
||||||
|
// Start the hand-driven session at the effort configured for the model this task would run
|
||||||
|
// under, instead of inheriting whatever the user's global Claude Code config happens to be.
|
||||||
|
// The model itself is deliberately NOT forced here — the user can still switch it in the TUI.
|
||||||
|
args = WithEffort(args, EffortFor(globalSettings, task.Model ?? listConfig?.Model));
|
||||||
|
|
||||||
// Same run environment variable ClaudeProcess sets for every headless run: the
|
// Same run environment variable ClaudeProcess sets for every headless run: the
|
||||||
// AskUser MCP tool call caps at 60s unless raised, and lifting it is harmless for
|
// AskUser MCP tool call caps at 60s unless raised, and lifting it is harmless for
|
||||||
// every other tool.
|
// every other tool.
|
||||||
@@ -123,7 +128,10 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
|
|||||||
};
|
};
|
||||||
|
|
||||||
return new LaunchSpec(
|
return new LaunchSpec(
|
||||||
ctx.WorkingDir, resolvedClaude, WindowsTerminalLauncher.BuildPlanningStartArgs(ctx), env);
|
ctx.WorkingDir, resolvedClaude,
|
||||||
|
WithEffort(WindowsTerminalLauncher.BuildPlanningStartArgs(ctx),
|
||||||
|
EffortFor(ReadSettings(), ModelRegistry.PlanningAlias)),
|
||||||
|
env);
|
||||||
}
|
}
|
||||||
|
|
||||||
public LaunchSpec BuildPlanningResume(PlanningSessionResumeContext ctx)
|
public LaunchSpec BuildPlanningResume(PlanningSessionResumeContext ctx)
|
||||||
@@ -138,10 +146,13 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
|
|||||||
};
|
};
|
||||||
|
|
||||||
return new LaunchSpec(
|
return new LaunchSpec(
|
||||||
ctx.WorkingDir, resolvedClaude, WindowsTerminalLauncher.BuildPlanningResumeArgs(ctx.ClaudeSessionId), env);
|
ctx.WorkingDir, resolvedClaude,
|
||||||
|
WithEffort(WindowsTerminalLauncher.BuildPlanningResumeArgs(ctx.ClaudeSessionId),
|
||||||
|
EffortFor(ReadSettings(), ModelRegistry.PlanningAlias)),
|
||||||
|
env);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<LaunchSpec> BuildForDirectoryAsync(string directory, CancellationToken ct)
|
public async Task<LaunchSpec> BuildForDirectoryAsync(string directory, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (!Directory.Exists(directory))
|
if (!Directory.Exists(directory))
|
||||||
throw new InvalidOperationException($"Directory does not exist: {directory}");
|
throw new InvalidOperationException($"Directory does not exist: {directory}");
|
||||||
@@ -149,12 +160,18 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
|
|||||||
var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath)
|
var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath)
|
||||||
?? throw new InvalidOperationException($"claude executable not found: {_claudePath}");
|
?? throw new InvalidOperationException($"claude executable not found: {_claudePath}");
|
||||||
|
|
||||||
|
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||||
|
var settings = await new AppSettingsRepository(ctx).GetAsync(ct);
|
||||||
|
|
||||||
var env = new Dictionary<string, string>
|
var env = new Dictionary<string, string>
|
||||||
{
|
{
|
||||||
["MCP_TOOL_TIMEOUT"] = "200000",
|
["MCP_TOOL_TIMEOUT"] = "200000",
|
||||||
};
|
};
|
||||||
|
|
||||||
return Task.FromResult(new LaunchSpec(directory, resolvedClaude, Array.Empty<string>(), env));
|
// No task and no list here — the global default model's preset decides the effort.
|
||||||
|
return new LaunchSpec(
|
||||||
|
directory, resolvedClaude,
|
||||||
|
WithEffort(Array.Empty<string>(), EffortFor(settings, settings.DefaultModel)), env);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tools the merge helper may use without prompting: the claudedo MCP surface (run, poll,
|
// Tools the merge helper may use without prompting: the claudedo MCP surface (run, poll,
|
||||||
@@ -163,51 +180,28 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
|
|||||||
private const string MergeHelperAllowedTools =
|
private const string MergeHelperAllowedTools =
|
||||||
"mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill";
|
"mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill";
|
||||||
|
|
||||||
public async Task<LaunchSpec> BuildForMergeHelperAsync(IReadOnlyList<string> taskIds, string? listId, CancellationToken ct)
|
public async Task<LaunchSpec> BuildForMergeHelperAsync(IReadOnlyList<string> taskIds, string listId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (taskIds.Count == 0)
|
if (taskIds.Count == 0)
|
||||||
throw new InvalidOperationException("No tasks selected for the merge helper.");
|
throw new InvalidOperationException("No tasks selected for the list handler.");
|
||||||
|
|
||||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||||
var taskRepo = new TaskRepository(ctx);
|
var taskRepo = new TaskRepository(ctx);
|
||||||
var listRepo = new ListRepository(ctx);
|
var listRepo = new ListRepository(ctx);
|
||||||
|
|
||||||
var listsById = new Dictionary<string, ListEntity?>();
|
var list = await listRepo.GetByIdAsync(listId, ct)
|
||||||
var briefLines = new List<string>();
|
?? throw new KeyNotFoundException($"List not found: {listId}");
|
||||||
var repoDirs = new List<string>(); // distinct, existing, in first-seen order
|
|
||||||
|
|
||||||
|
var repoDir = list.WorkingDir;
|
||||||
|
if (string.IsNullOrEmpty(repoDir) || !Directory.Exists(repoDir))
|
||||||
|
throw new InvalidOperationException($"list '{list.Name}' has no existing working directory");
|
||||||
|
|
||||||
|
var briefLines = new List<string>();
|
||||||
foreach (var id in taskIds)
|
foreach (var id in taskIds)
|
||||||
{
|
{
|
||||||
var task = await taskRepo.GetByIdAsync(id, ct)
|
var task = await taskRepo.GetByIdAsync(id, ct)
|
||||||
?? throw new KeyNotFoundException($"Task not found: {id}");
|
?? throw new KeyNotFoundException($"Task not found: {id}");
|
||||||
if (!listsById.TryGetValue(task.ListId, out var list))
|
briefLines.Add($"- [{task.Status}] {task.Title} (id: {task.Id})");
|
||||||
listsById[task.ListId] = list = await listRepo.GetByIdAsync(task.ListId, ct);
|
|
||||||
|
|
||||||
var workingDir = list?.WorkingDir;
|
|
||||||
if (!string.IsNullOrEmpty(workingDir) && Directory.Exists(workingDir) && !repoDirs.Contains(workingDir))
|
|
||||||
repoDirs.Add(workingDir);
|
|
||||||
|
|
||||||
briefLines.Add(
|
|
||||||
$"- [{task.Status}] {task.Title} (id: {task.Id}, list: {list?.Name ?? "—"}, " +
|
|
||||||
$"repo: {(string.IsNullOrEmpty(workingDir) ? "—" : workingDir)})");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (repoDirs.Count == 0)
|
|
||||||
throw new InvalidOperationException("none of the selected tasks are in a working directory");
|
|
||||||
|
|
||||||
string scopeLabel;
|
|
||||||
string cwd;
|
|
||||||
if (listId is not null)
|
|
||||||
{
|
|
||||||
var scopeList = await listRepo.GetByIdAsync(listId, ct)
|
|
||||||
?? throw new KeyNotFoundException($"List not found: {listId}");
|
|
||||||
scopeLabel = $"List: {scopeList.Name}";
|
|
||||||
cwd = scopeList.WorkingDir is { Length: > 0 } wd && Directory.Exists(wd) ? wd : repoDirs[0];
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
scopeLabel = "All lists";
|
|
||||||
cwd = repoDirs[0];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var sessionDir = Path.Combine(Paths.AppDataRoot(), "merge-helper-sessions", Guid.NewGuid().ToString());
|
var sessionDir = Path.Combine(Paths.AppDataRoot(), "merge-helper-sessions", Guid.NewGuid().ToString());
|
||||||
@@ -220,7 +214,8 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
|
|||||||
await File.WriteAllTextAsync(briefPath, PromptFiles.Render(PromptKind.MergeHelperInitial,
|
await File.WriteAllTextAsync(briefPath, PromptFiles.Render(PromptKind.MergeHelperInitial,
|
||||||
new Dictionary<string, string>
|
new Dictionary<string, string>
|
||||||
{
|
{
|
||||||
["scope"] = scopeLabel,
|
["scope"] = $"List: {list.Name}",
|
||||||
|
["repo"] = repoDir,
|
||||||
["tasks"] = string.Join("\n", briefLines),
|
["tasks"] = string.Join("\n", briefLines),
|
||||||
}), ct);
|
}), ct);
|
||||||
|
|
||||||
@@ -231,25 +226,46 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
|
|||||||
// (--allowedTools, --add-dir) first, then a single-value flag, then the single-line
|
// (--allowedTools, --add-dir) first, then a single-value flag, then the single-line
|
||||||
// positional kickoff LAST — a multi-line positional prompt truncates at the first
|
// positional kickoff LAST — a multi-line positional prompt truncates at the first
|
||||||
// newline, so the full multi-line brief travels via the file exposed through --add-dir.
|
// newline, so the full multi-line brief travels via the file exposed through --add-dir.
|
||||||
|
var listConfig = await listRepo.GetConfigAsync(listId, ct);
|
||||||
|
var settings = await new AppSettingsRepository(ctx).GetAsync(ct);
|
||||||
|
|
||||||
var args = new List<string>
|
var args = new List<string>
|
||||||
{
|
{
|
||||||
|
"--effort", EffortFor(settings, listConfig?.Model),
|
||||||
"--permission-mode", "default",
|
"--permission-mode", "default",
|
||||||
"--allowedTools", MergeHelperAllowedTools,
|
"--allowedTools", MergeHelperAllowedTools,
|
||||||
"--add-dir", sessionDir,
|
"--add-dir", sessionDir, repoDir,
|
||||||
|
"--append-system-prompt-file", systemPromptPath,
|
||||||
|
$"Read the file {briefPath} first. It lists the tasks you must handle and their status. " +
|
||||||
|
"After reading it, begin the session as your instructions describe.",
|
||||||
};
|
};
|
||||||
args.AddRange(repoDirs);
|
|
||||||
args.Add("--append-system-prompt-file");
|
|
||||||
args.Add(systemPromptPath);
|
|
||||||
args.Add(
|
|
||||||
$"Read the file {briefPath} first. It lists the tasks you must merge and their status. " +
|
|
||||||
"After reading it, begin the merge-helper session as your instructions describe.");
|
|
||||||
|
|
||||||
var env = new Dictionary<string, string>
|
var env = new Dictionary<string, string>
|
||||||
{
|
{
|
||||||
["MCP_TOOL_TIMEOUT"] = "200000",
|
["MCP_TOOL_TIMEOUT"] = "200000",
|
||||||
};
|
};
|
||||||
|
|
||||||
return new LaunchSpec(cwd, resolvedClaude, args, env);
|
return new LaunchSpec(repoDir, resolvedClaude, args, env);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The reasoning effort configured for a model in Settings → General. Falls back to the shipped
|
||||||
|
// preset for that model, so a missing/malformed settings row can never block a launch.
|
||||||
|
private static string EffortFor(AppSettingsEntity settings, string? model)
|
||||||
|
=> ModelPresets.For(settings.ModelPresets, model ?? settings.DefaultModel).Effort;
|
||||||
|
|
||||||
|
// Prepends `--effort <level>`. It has to lead: a positional kickoff prompt must stay last, and
|
||||||
|
// it may only follow a single-value flag — a variadic flag would swallow it.
|
||||||
|
private static IReadOnlyList<string> WithEffort(IReadOnlyList<string> args, string effort)
|
||||||
|
{
|
||||||
|
var result = new List<string>(args.Count + 2) { "--effort", effort };
|
||||||
|
result.AddRange(args);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private AppSettingsEntity ReadSettings()
|
||||||
|
{
|
||||||
|
using var ctx = _dbFactory.CreateDbContext();
|
||||||
|
return new AppSettingsRepository(ctx).GetAsync().GetAwaiter().GetResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
// The positional prompt claude opens the interactive session on. Empty (no positional arg)
|
// The positional prompt claude opens the interactive session on. Empty (no positional arg)
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ public interface IInteractiveLaunchSpecService
|
|||||||
/// <summary>Builds a LaunchSpec for an embedded ConPTY "merge helper" session that drives the
|
/// <summary>Builds a LaunchSpec for an embedded ConPTY "merge helper" session that drives the
|
||||||
/// given tasks to a merged/Done state via the mcp__claudedo__* tools. Writes a per-session
|
/// given tasks to a merged/Done state via the mcp__claudedo__* tools. Writes a per-session
|
||||||
/// system prompt + task brief under ~/.todo-app/merge-helper-sessions/<guid> and exposes
|
/// system prompt + task brief under ~/.todo-app/merge-helper-sessions/<guid> and exposes
|
||||||
/// that dir plus every distinct existing repo dir via --add-dir. listId scopes the brief label
|
/// that dir plus the list's repo dir via --add-dir. cwd is the list's working directory.
|
||||||
/// and the cwd to that list; null means all lists (cwd = first existing repo dir). Throws
|
/// Throws KeyNotFoundException if the list doesn't exist; InvalidOperationException if
|
||||||
/// InvalidOperationException if taskIds is empty or no task has an existing working directory.</summary>
|
/// taskIds is empty or the list has no existing working directory.</summary>
|
||||||
Task<LaunchSpec> BuildForMergeHelperAsync(IReadOnlyList<string> taskIds, string? listId, CancellationToken ct);
|
Task<LaunchSpec> BuildForMergeHelperAsync(IReadOnlyList<string> taskIds, string listId, CancellationToken ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -516,14 +516,20 @@ public sealed class TaskRunner
|
|||||||
var requestedSkills = UnionSkillNames(task.SessionSkills, listConfig?.SessionSkills, global.SessionSkills);
|
var requestedSkills = UnionSkillNames(task.SessionSkills, listConfig?.SessionSkills, global.SessionSkills);
|
||||||
var skillNames = await FilterToInstalledSkillsAsync(task.Id, requestedSkills, ct);
|
var skillNames = await FilterToInstalledSkillsAsync(task.Id, requestedSkills, ct);
|
||||||
|
|
||||||
|
// The model decides the global effort/turn defaults: one preset row per model alias
|
||||||
|
// (Settings → General). List- and task-level max-turns overrides still win.
|
||||||
|
var model = task.Model ?? listConfig?.Model ?? global.DefaultModel;
|
||||||
|
var preset = Data.Models.ModelPresets.For(global.ModelPresets, model);
|
||||||
|
|
||||||
return new ClaudeRunConfig(
|
return new ClaudeRunConfig(
|
||||||
Model: task.Model ?? listConfig?.Model ?? global.DefaultModel,
|
Model: model,
|
||||||
SystemPrompt: string.IsNullOrWhiteSpace(instructions) ? null : instructions,
|
SystemPrompt: string.IsNullOrWhiteSpace(instructions) ? null : instructions,
|
||||||
AgentPath: task.AgentPath ?? listConfig?.AgentPath,
|
AgentPath: task.AgentPath ?? listConfig?.AgentPath,
|
||||||
ResumeSessionId: resumeSessionId,
|
ResumeSessionId: resumeSessionId,
|
||||||
MaxTurns: ResolveMaxTurns(task.MaxTurns, listConfig?.MaxTurns, global.DefaultMaxTurns),
|
MaxTurns: ResolveMaxTurns(task.MaxTurns, listConfig?.MaxTurns, preset.MaxTurns),
|
||||||
PermissionMode: global.DefaultPermissionMode,
|
PermissionMode: global.DefaultPermissionMode,
|
||||||
SkillNames: skillNames);
|
SkillNames: skillNames,
|
||||||
|
Effort: preset.Effort);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<IReadOnlyList<string>> FilterToInstalledSkillsAsync(
|
private async Task<IReadOnlyList<string>> FilterToInstalledSkillsAsync(
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ public interface ITaskStateService
|
|||||||
Task<TransitionResult> SubmitInteractiveForReviewAsync(string taskId, DateTime finishedAt, CancellationToken ct);
|
Task<TransitionResult> SubmitInteractiveForReviewAsync(string taskId, DateTime finishedAt, CancellationToken ct);
|
||||||
Task<TransitionResult> SubmitForChildrenAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct);
|
Task<TransitionResult> SubmitForChildrenAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct);
|
||||||
Task<TransitionResult> FailAsync(string taskId, DateTime finishedAt, string? error, CancellationToken ct);
|
Task<TransitionResult> FailAsync(string taskId, DateTime finishedAt, string? error, CancellationToken ct);
|
||||||
Task<TransitionResult> CancelAsync(string taskId, DateTime finishedAt, CancellationToken ct);
|
Task<TransitionResult> CancelAsync(string taskId, DateTime finishedAt, CancellationToken ct, bool allowFromIdle = false);
|
||||||
Task<TransitionResult> ResetToIdleAsync(string taskId, CancellationToken ct);
|
Task<TransitionResult> ResetToIdleAsync(string taskId, CancellationToken ct);
|
||||||
|
|
||||||
Task<TransitionResult> ApproveReviewAsync(string taskId, CancellationToken ct);
|
Task<TransitionResult> ApproveReviewAsync(string taskId, CancellationToken ct);
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ public sealed class TaskStateService : ITaskStateService
|
|||||||
if (await IsDraftChildAsync(ctx, taskId, ct))
|
if (await IsDraftChildAsync(ctx, taskId, ct))
|
||||||
return new TransitionResult(false, "Draft subtask: finalize the plan before queuing it.");
|
return new TransitionResult(false, "Draft subtask: finalize the plan before queuing it.");
|
||||||
|
|
||||||
|
if (await IsManualAsync(ctx, taskId, ct))
|
||||||
|
return new TransitionResult(false, "Manual task: mark it as a Claude task before queuing it.");
|
||||||
|
|
||||||
var affected = await ctx.Tasks
|
var affected = await ctx.Tasks
|
||||||
.Where(t => t.Id == taskId && t.Status != TaskStatus.Running)
|
.Where(t => t.Id == taskId && t.Status != TaskStatus.Running)
|
||||||
.ExecuteUpdateAsync(s => s.SetProperty(t => t.Status, TaskStatus.Queued), ct);
|
.ExecuteUpdateAsync(s => s.SetProperty(t => t.Status, TaskStatus.Queued), ct);
|
||||||
@@ -60,6 +63,9 @@ public sealed class TaskStateService : ITaskStateService
|
|||||||
if (await IsDraftChildAsync(ctx, taskId, ct))
|
if (await IsDraftChildAsync(ctx, taskId, ct))
|
||||||
return new TransitionResult(false, "Draft subtask: finalize the plan before running it.");
|
return new TransitionResult(false, "Draft subtask: finalize the plan before running it.");
|
||||||
|
|
||||||
|
if (await IsManualAsync(ctx, taskId, ct))
|
||||||
|
return new TransitionResult(false, "Manual task: mark it as a Claude task before running it.");
|
||||||
|
|
||||||
var affected = await ctx.Tasks
|
var affected = await ctx.Tasks
|
||||||
.Where(t => t.Id == taskId && t.Status != TaskStatus.Running)
|
.Where(t => t.Id == taskId && t.Status != TaskStatus.Running)
|
||||||
.ExecuteUpdateAsync(s => s
|
.ExecuteUpdateAsync(s => s
|
||||||
@@ -241,7 +247,13 @@ public sealed class TaskStateService : ITaskStateService
|
|||||||
return new TransitionResult(true, null);
|
return new TransitionResult(true, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<TransitionResult> CancelAsync(string taskId, DateTime finishedAt, CancellationToken ct)
|
// allowFromIdle: only the external update_task_status(Cancelled) path sets this — it lets
|
||||||
|
// an Idle task be retired without deleting it. Every other caller (hub CancelReview,
|
||||||
|
// PlanningChainCoordinator's chain-walk, batch cancel) relies on Idle staying a no-op here;
|
||||||
|
// PlanningChainCoordinator specifically uses "parked back to Idle" as a deliberate opt-out
|
||||||
|
// signal, so do not flip this default.
|
||||||
|
public async Task<TransitionResult> CancelAsync(
|
||||||
|
string taskId, DateTime finishedAt, CancellationToken ct, bool allowFromIdle = false)
|
||||||
{
|
{
|
||||||
List<string> cancelledChildIds;
|
List<string> cancelledChildIds;
|
||||||
await using (var ctx = await _dbFactory.CreateDbContextAsync(ct))
|
await using (var ctx = await _dbFactory.CreateDbContextAsync(ct))
|
||||||
@@ -250,7 +262,8 @@ public sealed class TaskStateService : ITaskStateService
|
|||||||
.Where(t => t.Id == taskId &&
|
.Where(t => t.Id == taskId &&
|
||||||
(t.Status == TaskStatus.Running || t.Status == TaskStatus.Queued
|
(t.Status == TaskStatus.Running || t.Status == TaskStatus.Queued
|
||||||
|| t.Status == TaskStatus.WaitingForReview
|
|| t.Status == TaskStatus.WaitingForReview
|
||||||
|| t.Status == TaskStatus.WaitingForChildren))
|
|| t.Status == TaskStatus.WaitingForChildren
|
||||||
|
|| (allowFromIdle && t.Status == TaskStatus.Idle)))
|
||||||
.ExecuteUpdateAsync(s => s
|
.ExecuteUpdateAsync(s => s
|
||||||
.SetProperty(t => t.Status, TaskStatus.Cancelled)
|
.SetProperty(t => t.Status, TaskStatus.Cancelled)
|
||||||
.SetProperty(t => t.FinishedAt, finishedAt), ct);
|
.SetProperty(t => t.FinishedAt, finishedAt), ct);
|
||||||
@@ -411,6 +424,11 @@ public sealed class TaskStateService : ITaskStateService
|
|||||||
// A subtask is "draft" only while its planning parent has an open (Active) session.
|
// A subtask is "draft" only while its planning parent has an open (Active) session.
|
||||||
// Improvement children whose parent has PlanningPhase.None are not drafts and may be
|
// Improvement children whose parent has PlanningPhase.None are not drafts and may be
|
||||||
// queued freely. Standalone tasks (no parent) are never draft.
|
// queued freely. Standalone tasks (no parent) are never draft.
|
||||||
|
// Server-side backstop for the manual flag: the UI hides the hand-off affordances, but the
|
||||||
|
// MCP surface and hub can still be driven directly.
|
||||||
|
private static Task<bool> IsManualAsync(ClaudeDoDbContext ctx, string taskId, CancellationToken ct)
|
||||||
|
=> ctx.Tasks.AsNoTracking().AnyAsync(t => t.Id == taskId && t.IsManual, ct);
|
||||||
|
|
||||||
private static async Task<bool> IsDraftChildAsync(ClaudeDoDbContext ctx, string taskId, CancellationToken ct)
|
private static async Task<bool> IsDraftChildAsync(ClaudeDoDbContext ctx, string taskId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var parentId = await ctx.Tasks.AsNoTracking()
|
var parentId = await ctx.Tasks.AsNoTracking()
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Data.Tests;
|
||||||
|
|
||||||
|
public class ModelPresetsTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Defaults_cover_every_known_alias()
|
||||||
|
{
|
||||||
|
Assert.Equal(
|
||||||
|
ModelRegistry.Aliases.OrderBy(a => a),
|
||||||
|
ModelPresets.Defaults.Select(p => p.Model).OrderBy(m => m));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null)]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData(" ")]
|
||||||
|
[InlineData("not json at all")]
|
||||||
|
public void Parse_falls_back_to_defaults_when_unusable(string? json)
|
||||||
|
{
|
||||||
|
Assert.Equal(ModelPresets.Defaults, ModelPresets.Parse(json));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Serialize_roundtrips()
|
||||||
|
{
|
||||||
|
var edited = new[] { new ModelPreset("opus", "max", 55) };
|
||||||
|
var parsed = ModelPresets.Parse(ModelPresets.Serialize(edited));
|
||||||
|
|
||||||
|
Assert.Equal("max", ModelPresets.For(parsed, "opus").Effort);
|
||||||
|
Assert.Equal(55, ModelPresets.For(parsed, "opus").MaxTurns);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Serialize_fills_missing_aliases_from_defaults()
|
||||||
|
{
|
||||||
|
var parsed = ModelPresets.Parse(ModelPresets.Serialize(new[] { new ModelPreset("opus", "max", 55) }));
|
||||||
|
|
||||||
|
Assert.Equal(ModelRegistry.Aliases.Count, parsed.Count);
|
||||||
|
Assert.Equal(ModelPresets.For(ModelPresets.Defaults, "haiku"), ModelPresets.For(parsed, "haiku"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("nonsense", 30)] // unknown effort → the default row's effort
|
||||||
|
[InlineData("high", 0)] // turns out of range → the default row's turns
|
||||||
|
[InlineData("high", 999)]
|
||||||
|
public void Serialize_rejects_out_of_range_values(string effort, int turns)
|
||||||
|
{
|
||||||
|
var fallback = ModelPresets.For(ModelPresets.Defaults, "sonnet");
|
||||||
|
var parsed = ModelPresets.Parse(ModelPresets.Serialize(new[] { new ModelPreset("sonnet", effort, turns) }));
|
||||||
|
var row = ModelPresets.For(parsed, "sonnet");
|
||||||
|
|
||||||
|
if (effort == "nonsense") Assert.Equal(fallback.Effort, row.Effort);
|
||||||
|
if (turns is 0 or 999) Assert.Equal(fallback.MaxTurns, row.MaxTurns);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void For_unknown_model_yields_a_usable_row()
|
||||||
|
{
|
||||||
|
var row = ModelPresets.For(ModelPresets.Defaults, "some-future-model");
|
||||||
|
|
||||||
|
Assert.Equal("some-future-model", row.Model);
|
||||||
|
Assert.Contains(row.Effort, EffortRegistry.Levels);
|
||||||
|
Assert.True(row.MaxTurns > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void For_null_model_uses_the_default_alias()
|
||||||
|
{
|
||||||
|
Assert.Equal(
|
||||||
|
ModelPresets.For(ModelPresets.Defaults, ModelRegistry.DefaultAlias),
|
||||||
|
ModelPresets.For(ModelPresets.Defaults, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("low", "low")]
|
||||||
|
[InlineData("XHIGH", "xhigh")]
|
||||||
|
[InlineData(" max ", "max")]
|
||||||
|
public void NormalizeLevel_canonicalizes(string input, string expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, EffortRegistry.NormalizeLevel(input));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NormalizeLevel_blank_means_unset_and_unknown_throws()
|
||||||
|
{
|
||||||
|
Assert.Null(EffortRegistry.NormalizeLevel(null));
|
||||||
|
Assert.Throws<ArgumentException>(() => EffortRegistry.NormalizeLevel("gigantic"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,12 +52,36 @@ public class PromptFilesTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void DefaultFor_merge_helper_is_non_empty_and_mentions_the_merge_tools()
|
public void DefaultFor_merge_helper_covers_all_five_phases()
|
||||||
{
|
{
|
||||||
var d = PromptFiles.DefaultFor(PromptKind.MergeHelper);
|
var d = PromptFiles.DefaultFor(PromptKind.MergeHelper);
|
||||||
Assert.False(string.IsNullOrWhiteSpace(d));
|
Assert.False(string.IsNullOrWhiteSpace(d));
|
||||||
Assert.Contains("review_task", d);
|
Assert.Contains("Phase 0", d);
|
||||||
Assert.Contains("continue_merge", d);
|
Assert.Contains("Phase 1", d);
|
||||||
|
Assert.Contains("Phase 2", d);
|
||||||
|
Assert.Contains("Phase 3", d);
|
||||||
|
Assert.Contains("Phase 4", d);
|
||||||
|
Assert.Contains("Phase 5", d);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DefaultFor_merge_helper_names_the_tools_each_phase_needs()
|
||||||
|
{
|
||||||
|
var d = PromptFiles.DefaultFor(PromptKind.MergeHelper);
|
||||||
|
Assert.Contains("batch_get_tasks", d); // phase 0
|
||||||
|
Assert.Contains("update_task", d); // phase 1 + 2
|
||||||
|
Assert.Contains("get_app_settings", d); // phase 3
|
||||||
|
Assert.Contains("update_task_status", d); // phase 3
|
||||||
|
Assert.Contains("review_task", d); // phase 4
|
||||||
|
Assert.Contains("continue_merge", d); // phase 4
|
||||||
|
Assert.DoesNotContain("run_task_now(", d); // single override slot — must not batch-start
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DefaultFor_merge_helper_initial_has_repo_token()
|
||||||
|
{
|
||||||
|
var d = PromptFiles.DefaultFor(PromptKind.MergeHelperInitial);
|
||||||
|
Assert.Contains("{repo}", d);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ public abstract class StubWorkerClient : IWorkerClient
|
|||||||
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
|
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
|
||||||
public virtual Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
|
public virtual Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
|
||||||
=> Task.FromResult(new LaunchSpec(directory, "claude", Array.Empty<string>(), new Dictionary<string, string>()));
|
=> Task.FromResult(new LaunchSpec(directory, "claude", Array.Empty<string>(), new Dictionary<string, string>()));
|
||||||
public virtual Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string? listId, CancellationToken ct = default)
|
public virtual Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, CancellationToken ct = default)
|
||||||
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
|
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
|
||||||
public virtual Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default)
|
public virtual Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default)
|
||||||
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
|
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
|
||||||
|
|||||||
@@ -26,11 +26,15 @@ public class AgentConfigEditorViewModelTests
|
|||||||
public override Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto) { SavedTaskSettings = dto; return Task.CompletedTask; }
|
public override Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto) { SavedTaskSettings = dto; return Task.CompletedTask; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The global turn default is per-model now, so the preset row for `model` is what carries
|
||||||
|
// `turns` — DefaultMaxTurns is no longer consulted.
|
||||||
private static AppSettingsDto AppWith(string model, int turns) =>
|
private static AppSettingsDto AppWith(string model, int turns) =>
|
||||||
new(DefaultClaudeInstructions: "", DefaultModel: model, DefaultMaxTurns: turns,
|
new(DefaultClaudeInstructions: "", DefaultModel: model, DefaultMaxTurns: turns,
|
||||||
DefaultPermissionMode: "auto", MaxParallelExecutions: 1, WorktreeStrategy: "sibling",
|
DefaultPermissionMode: "auto", MaxParallelExecutions: 1, WorktreeStrategy: "sibling",
|
||||||
CentralWorktreeRoot: null, WorktreeAutoCleanupEnabled: false, WorktreeAutoCleanupDays: 30,
|
CentralWorktreeRoot: null, WorktreeAutoCleanupEnabled: false, WorktreeAutoCleanupDays: 30,
|
||||||
ReportExcludedPaths: null, StandupWeekday: 3, DailyPrepMaxTasks: 5);
|
ReportExcludedPaths: null, StandupWeekday: 3, DailyPrepMaxTasks: 5,
|
||||||
|
SessionSkills: null,
|
||||||
|
ModelPresets: new List<ModelPresetDto> { new(model, "high", turns) });
|
||||||
|
|
||||||
private static TaskEntity TaskWith(string? model, int? turns, string? sp, string? agentPath) =>
|
private static TaskEntity TaskWith(string? model, int? turns, string? sp, string? agentPath) =>
|
||||||
new() { Id = "t1", ListId = "l1", Title = "t", CreatedAt = DateTime.UtcNow,
|
new() { Id = "t1", ListId = "l1", Title = "t", CreatedAt = DateTime.UtcNow,
|
||||||
|
|||||||
@@ -74,11 +74,12 @@ public class MergeHelperSelectionModalViewModelTests : IDisposable
|
|||||||
{
|
{
|
||||||
await SeedAllStatusesAsync();
|
await SeedAllStatusesAsync();
|
||||||
var vm = BuildVm();
|
var vm = BuildVm();
|
||||||
vm.Configure(null, null);
|
vm.Configure("L1", "Work");
|
||||||
await vm.LoadAsync();
|
await vm.LoadAsync();
|
||||||
|
|
||||||
Assert.DoesNotContain(vm.Tasks, t => t.Id is "t-done" or "t-cancelled");
|
Assert.DoesNotContain(vm.Tasks, t => t.Id is "t-done" or "t-cancelled");
|
||||||
Assert.Equal(7, vm.Tasks.Count);
|
Assert.DoesNotContain(vm.Tasks, t => t.Id == "t-other-list");
|
||||||
|
Assert.Equal(6, vm.Tasks.Count);
|
||||||
|
|
||||||
Assert.True(vm.Tasks.Single(t => t.Id == "t-idle").IsSelected);
|
Assert.True(vm.Tasks.Single(t => t.Id == "t-idle").IsSelected);
|
||||||
Assert.True(vm.Tasks.Single(t => t.Id == "t-queued").IsSelected);
|
Assert.True(vm.Tasks.Single(t => t.Id == "t-queued").IsSelected);
|
||||||
@@ -98,8 +99,6 @@ public class MergeHelperSelectionModalViewModelTests : IDisposable
|
|||||||
|
|
||||||
Assert.Single(vm.Tasks);
|
Assert.Single(vm.Tasks);
|
||||||
Assert.Equal("t-other-list", vm.Tasks[0].Id);
|
Assert.Equal("t-other-list", vm.Tasks[0].Id);
|
||||||
Assert.Equal("Home", vm.Tasks[0].ListName);
|
|
||||||
Assert.False(vm.IsGlobal);
|
|
||||||
Assert.Contains("Home", vm.ScopeLabel);
|
Assert.Contains("Home", vm.ScopeLabel);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,7 +107,7 @@ public class MergeHelperSelectionModalViewModelTests : IDisposable
|
|||||||
{
|
{
|
||||||
await SeedAllStatusesAsync();
|
await SeedAllStatusesAsync();
|
||||||
var vm = BuildVm();
|
var vm = BuildVm();
|
||||||
vm.Configure(null, null);
|
vm.Configure("L1", "Work");
|
||||||
await vm.LoadAsync();
|
await vm.LoadAsync();
|
||||||
|
|
||||||
Assert.True(vm.CanConfirm);
|
Assert.True(vm.CanConfirm);
|
||||||
@@ -129,7 +128,7 @@ public class MergeHelperSelectionModalViewModelTests : IDisposable
|
|||||||
{
|
{
|
||||||
await SeedAllStatusesAsync();
|
await SeedAllStatusesAsync();
|
||||||
var vm = BuildVm();
|
var vm = BuildVm();
|
||||||
vm.Configure(null, null);
|
vm.Configure("L1", "Work");
|
||||||
await vm.LoadAsync();
|
await vm.LoadAsync();
|
||||||
|
|
||||||
vm.SelectNoneCommand.Execute(null);
|
vm.SelectNoneCommand.Execute(null);
|
||||||
@@ -152,7 +151,7 @@ public class MergeHelperSelectionModalViewModelTests : IDisposable
|
|||||||
{
|
{
|
||||||
await SeedAllStatusesAsync();
|
await SeedAllStatusesAsync();
|
||||||
var vm = BuildVm();
|
var vm = BuildVm();
|
||||||
vm.Configure(null, null);
|
vm.Configure("L1", "Work");
|
||||||
await vm.LoadAsync();
|
await vm.LoadAsync();
|
||||||
|
|
||||||
var closed = false;
|
var closed = false;
|
||||||
|
|||||||
@@ -298,7 +298,7 @@ public class MissionControlViewModelTests : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task OpenConPtySessionAsync_WorkerThrows_RaisesErrorReported_NoPaneAdded()
|
public async Task OpenConPtySessionAsync_WorkerThrows_RaisesErrorReported_PaneShowsFailure()
|
||||||
{
|
{
|
||||||
var worker = new ThrowingLaunchSpecWorker();
|
var worker = new ThrowingLaunchSpecWorker();
|
||||||
using var vm = BuildVm(worker);
|
using var vm = BuildVm(worker);
|
||||||
@@ -307,8 +307,10 @@ public class MissionControlViewModelTests : IDisposable
|
|||||||
|
|
||||||
await vm.OpenConPtySessionAsync("t1");
|
await vm.OpenConPtySessionAsync("t1");
|
||||||
|
|
||||||
Assert.Empty(vm.ConPtySessions);
|
// The tile is shown up-front (spinner) and keeps the failure inline rather than vanishing.
|
||||||
Assert.Empty(vm.Panes);
|
var pane = Assert.Single(vm.ConPtySessions);
|
||||||
|
Assert.NotNull(pane.Terminal.StartError);
|
||||||
|
Assert.False(pane.Terminal.IsStarting);
|
||||||
Assert.NotNull(error);
|
Assert.NotNull(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,7 +380,7 @@ public class MissionControlViewModelTests : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task OpenAdHocConPtySessionAsync_WorkerThrows_RaisesErrorReported_NoPaneAdded()
|
public async Task OpenAdHocConPtySessionAsync_WorkerThrows_RaisesErrorReported_PaneShowsFailure()
|
||||||
{
|
{
|
||||||
var worker = new ThrowingAdHocLaunchSpecWorker();
|
var worker = new ThrowingAdHocLaunchSpecWorker();
|
||||||
using var vm = BuildVm(worker);
|
using var vm = BuildVm(worker);
|
||||||
@@ -387,8 +389,9 @@ public class MissionControlViewModelTests : IDisposable
|
|||||||
|
|
||||||
await vm.OpenAdHocConPtySessionAsync(Path.Combine("C:", "Some", "Path"));
|
await vm.OpenAdHocConPtySessionAsync(Path.Combine("C:", "Some", "Path"));
|
||||||
|
|
||||||
Assert.Empty(vm.ConPtySessions);
|
var pane = Assert.Single(vm.ConPtySessions);
|
||||||
Assert.Empty(vm.Panes);
|
Assert.NotNull(pane.Terminal.StartError);
|
||||||
|
Assert.False(pane.Terminal.IsStarting);
|
||||||
Assert.NotNull(error);
|
Assert.NotNull(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -408,7 +411,7 @@ public class MissionControlViewModelTests : IDisposable
|
|||||||
|
|
||||||
private sealed class ThrowingMergeHelperLaunchSpecWorker : StubWorkerClient
|
private sealed class ThrowingMergeHelperLaunchSpecWorker : StubWorkerClient
|
||||||
{
|
{
|
||||||
public override Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string? listId, CancellationToken ct = default)
|
public override Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, CancellationToken ct = default)
|
||||||
=> throw new InvalidOperationException("spec failed");
|
=> throw new InvalidOperationException("spec failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,7 +421,7 @@ public class MissionControlViewModelTests : IDisposable
|
|||||||
var worker = new FakeWorker();
|
var worker = new FakeWorker();
|
||||||
using var vm = BuildVm(worker);
|
using var vm = BuildVm(worker);
|
||||||
|
|
||||||
await vm.OpenMergeHelperConPtySessionAsync(null, new[] { "t1", "t2" });
|
await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1", "t2" });
|
||||||
|
|
||||||
Assert.Single(vm.ConPtySessions);
|
Assert.Single(vm.ConPtySessions);
|
||||||
Assert.Null(vm.ConPtySessions[0].TaskId);
|
Assert.Null(vm.ConPtySessions[0].TaskId);
|
||||||
@@ -432,8 +435,8 @@ public class MissionControlViewModelTests : IDisposable
|
|||||||
var worker = new FakeWorker();
|
var worker = new FakeWorker();
|
||||||
using var vm = BuildVm(worker);
|
using var vm = BuildVm(worker);
|
||||||
|
|
||||||
await vm.OpenMergeHelperConPtySessionAsync(null, new[] { "t1" });
|
await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1" });
|
||||||
await vm.OpenMergeHelperConPtySessionAsync(null, new[] { "t1" });
|
await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1" });
|
||||||
|
|
||||||
Assert.Equal(2, vm.ConPtySessions.Count);
|
Assert.Equal(2, vm.ConPtySessions.Count);
|
||||||
}
|
}
|
||||||
@@ -444,24 +447,25 @@ public class MissionControlViewModelTests : IDisposable
|
|||||||
var worker = new FakeWorker();
|
var worker = new FakeWorker();
|
||||||
using var vm = BuildVm(worker);
|
using var vm = BuildVm(worker);
|
||||||
|
|
||||||
await vm.OpenMergeHelperConPtySessionAsync(null, Array.Empty<string>());
|
await vm.OpenMergeHelperConPtySessionAsync("L1", Array.Empty<string>());
|
||||||
|
|
||||||
Assert.Empty(vm.ConPtySessions);
|
Assert.Empty(vm.ConPtySessions);
|
||||||
Assert.Empty(vm.Panes);
|
Assert.Empty(vm.Panes);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task OpenMergeHelperConPtySessionAsync_WorkerThrows_RaisesErrorReported_NoPaneAdded()
|
public async Task OpenMergeHelperConPtySessionAsync_WorkerThrows_RaisesErrorReported_PaneShowsFailure()
|
||||||
{
|
{
|
||||||
var worker = new ThrowingMergeHelperLaunchSpecWorker();
|
var worker = new ThrowingMergeHelperLaunchSpecWorker();
|
||||||
using var vm = BuildVm(worker);
|
using var vm = BuildVm(worker);
|
||||||
string? error = null;
|
string? error = null;
|
||||||
vm.ErrorReported += msg => error = msg;
|
vm.ErrorReported += msg => error = msg;
|
||||||
|
|
||||||
await vm.OpenMergeHelperConPtySessionAsync(null, new[] { "t1" });
|
await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1" });
|
||||||
|
|
||||||
Assert.Empty(vm.ConPtySessions);
|
var pane = Assert.Single(vm.ConPtySessions);
|
||||||
Assert.Empty(vm.Panes);
|
Assert.NotNull(pane.Terminal.StartError);
|
||||||
|
Assert.False(pane.Terminal.IsStarting);
|
||||||
Assert.NotNull(error);
|
Assert.NotNull(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -380,6 +380,32 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
|||||||
Assert.Equal(TaskStatus.WaitingForReview, reloadedParent!.Status);
|
Assert.Equal(TaskStatus.WaitingForReview, reloadedParent!.Status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateTaskStatus_Cancelled_CancelsAnIdleTask()
|
||||||
|
{
|
||||||
|
var listId = await SeedListAsync();
|
||||||
|
var task = await SeedTaskAsync(listId, status: TaskStatus.Idle);
|
||||||
|
var sut = BuildSut(CreateQueue());
|
||||||
|
|
||||||
|
var dto = await sut.UpdateTaskStatus(task.Id, "Cancelled", CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal("Cancelled", dto.Status);
|
||||||
|
var loaded = await _tasks.GetByIdAsync(task.Id);
|
||||||
|
Assert.Equal(TaskStatus.Cancelled, loaded!.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateTaskStatus_Done_StillRejected()
|
||||||
|
{
|
||||||
|
var listId = await SeedListAsync();
|
||||||
|
var task = await SeedTaskAsync(listId, status: TaskStatus.Idle);
|
||||||
|
var sut = BuildSut(CreateQueue());
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => sut.UpdateTaskStatus(task.Id, "Done", CancellationToken.None));
|
||||||
|
Assert.Contains("not settable externally", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
private ExternalMcpService NewService() => BuildSut(CreateQueue());
|
private ExternalMcpService NewService() => BuildSut(CreateQueue());
|
||||||
|
|
||||||
private async Task<string> SeedIdleTask(string title = "t")
|
private async Task<string> SeedIdleTask(string title = "t")
|
||||||
|
|||||||
@@ -48,7 +48,8 @@ public sealed class QueuePickerTests : IDisposable
|
|||||||
DateTime? scheduledFor = null,
|
DateTime? scheduledFor = null,
|
||||||
string? blockedBy = null,
|
string? blockedBy = null,
|
||||||
bool taskAgentTag = false,
|
bool taskAgentTag = false,
|
||||||
int? sortOrder = null)
|
int? sortOrder = null,
|
||||||
|
bool isManual = false)
|
||||||
{
|
{
|
||||||
var task = new TaskEntity
|
var task = new TaskEntity
|
||||||
{
|
{
|
||||||
@@ -60,6 +61,7 @@ public sealed class QueuePickerTests : IDisposable
|
|||||||
ScheduledFor = scheduledFor,
|
ScheduledFor = scheduledFor,
|
||||||
BlockedByTaskId = blockedBy,
|
BlockedByTaskId = blockedBy,
|
||||||
CommitType = "feat",
|
CommitType = "feat",
|
||||||
|
IsManual = isManual,
|
||||||
};
|
};
|
||||||
await _tasks.AddAsync(task);
|
await _tasks.AddAsync(task);
|
||||||
if (sortOrder is not null)
|
if (sortOrder is not null)
|
||||||
@@ -70,6 +72,28 @@ public sealed class QueuePickerTests : IDisposable
|
|||||||
return task;
|
return task;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ClaimNextAsync_Skips_ManualTasks()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
await SeedAsync(listId, isManual: true);
|
||||||
|
|
||||||
|
Assert.Null(await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ClaimNextAsync_Skips_ManualTask_ButClaimsTheNextOne()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
await SeedAsync(listId, createdAt: DateTime.UtcNow.AddMinutes(-5), isManual: true);
|
||||||
|
var claudeTask = await SeedAsync(listId, createdAt: DateTime.UtcNow);
|
||||||
|
|
||||||
|
var picked = await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.NotNull(picked);
|
||||||
|
Assert.Equal(claudeTask.Id, picked!.Id);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ClaimNextAsync_Skips_TasksWithBlockedByTaskId()
|
public async Task ClaimNextAsync_Skips_TasksWithBlockedByTaskId()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -22,6 +22,25 @@ public sealed class ClaudeArgsBuilderTests
|
|||||||
Assert.DoesNotContain("--append-system-prompt", args);
|
Assert.DoesNotContain("--append-system-prompt", args);
|
||||||
Assert.DoesNotContain("--agents", args);
|
Assert.DoesNotContain("--agents", args);
|
||||||
Assert.DoesNotContain("--resume", args);
|
Assert.DoesNotContain("--resume", args);
|
||||||
|
Assert.DoesNotContain("--effort", args);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Effort_Adds_Effort_Flag()
|
||||||
|
{
|
||||||
|
var args = _builder.Build(new ClaudeRunConfig(null, null, null, null, Effort: "xhigh"));
|
||||||
|
Assert.Contains("--effort", args);
|
||||||
|
Assert.Equal("xhigh", args[args.ToList().IndexOf("--effort") + 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null)]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData(" ")]
|
||||||
|
public void Blank_Effort_Omits_The_Flag(string? effort)
|
||||||
|
{
|
||||||
|
var args = _builder.Build(new ClaudeRunConfig(null, null, null, null, Effort: effort));
|
||||||
|
Assert.DoesNotContain("--effort", args);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
|||||||
Assert.True(Directory.Exists(wtRow.Path));
|
Assert.True(Directory.Exists(wtRow.Path));
|
||||||
|
|
||||||
Assert.Equal(wtRow.Path, spec.Cwd);
|
Assert.Equal(wtRow.Path, spec.Cwd);
|
||||||
Assert.Equal(new[] { "T" }, spec.Args); // fresh session seeds the task title as the prompt
|
Assert.Equal(new[] { "T" }, ArgsAfterEffort(spec)); // fresh session seeds the task title as the prompt
|
||||||
Assert.Equal(_claudeStubPath, spec.Exe);
|
Assert.Equal(_claudeStubPath, spec.Exe);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,7 +230,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
|||||||
|
|
||||||
Assert.Equal(_worktreeDir, spec.Cwd);
|
Assert.Equal(_worktreeDir, spec.Cwd);
|
||||||
Assert.Equal(_claudeStubPath, spec.Exe);
|
Assert.Equal(_claudeStubPath, spec.Exe);
|
||||||
Assert.Equal(new[] { "--resume", "sess-123" }, spec.Args);
|
Assert.Equal(new[] { "--resume", "sess-123" }, ArgsAfterEffort(spec));
|
||||||
Assert.Equal("200000", spec.Env["MCP_TOOL_TIMEOUT"]);
|
Assert.Equal("200000", spec.Env["MCP_TOOL_TIMEOUT"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,7 +246,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
|||||||
var svc = BuildService();
|
var svc = BuildService();
|
||||||
var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);
|
var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(new[] { "--resume", "sess-kept" }, spec.Args);
|
Assert.Equal(new[] { "--resume", "sess-kept" }, ArgsAfterEffort(spec));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -261,7 +261,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
|||||||
var svc = BuildService();
|
var svc = BuildService();
|
||||||
var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);
|
var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(new[] { "T" }, spec.Args); // fresh: seeds the task title as the prompt
|
Assert.Equal(new[] { "T" }, ArgsAfterEffort(spec)); // fresh: seeds the task title as the prompt
|
||||||
Assert.Equal(_worktreeDir, spec.Cwd);
|
Assert.Equal(_worktreeDir, spec.Cwd);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,7 +277,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
|||||||
var svc = BuildService();
|
var svc = BuildService();
|
||||||
var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);
|
var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(new[] { "T" }, spec.Args); // fresh: seeds the task title as the prompt
|
Assert.Equal(new[] { "T" }, ArgsAfterEffort(spec)); // fresh: seeds the task title as the prompt
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -297,7 +297,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
|||||||
var svc = BuildService();
|
var svc = BuildService();
|
||||||
var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);
|
var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(new[] { "T\n\nDo the thing" }, spec.Args);
|
Assert.Equal(new[] { "T\n\nDo the thing" }, ArgsAfterEffort(spec));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -333,7 +333,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
|||||||
|
|
||||||
Assert.Equal(_tempDir, spec.Cwd);
|
Assert.Equal(_tempDir, spec.Cwd);
|
||||||
Assert.Equal(_claudeStubPath, spec.Exe);
|
Assert.Equal(_claudeStubPath, spec.Exe);
|
||||||
Assert.Empty(spec.Args);
|
Assert.Empty(ArgsAfterEffort(spec));
|
||||||
Assert.Equal("200000", spec.Env["MCP_TOOL_TIMEOUT"]);
|
Assert.Equal("200000", spec.Env["MCP_TOOL_TIMEOUT"]);
|
||||||
Assert.Empty(_seeder.Calls);
|
Assert.Empty(_seeder.Calls);
|
||||||
}
|
}
|
||||||
@@ -348,6 +348,17 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
|||||||
|
|
||||||
// ── Merge helper ──
|
// ── Merge helper ──
|
||||||
|
|
||||||
|
/// Every ConPTY spec now leads with `--effort <level>` from the per-model preset (the seeded
|
||||||
|
/// settings row has no overrides, so the shipped default for the default model applies).
|
||||||
|
/// Asserts that pair and returns the rest of the args for the test's own comparison.
|
||||||
|
private static string[] ArgsAfterEffort(LaunchSpec spec)
|
||||||
|
{
|
||||||
|
var args = spec.Args.ToList();
|
||||||
|
Assert.Equal("--effort", args[0]);
|
||||||
|
Assert.Equal(ModelPresets.For(ModelPresets.Defaults, ModelRegistry.DefaultAlias).Effort, args[1]);
|
||||||
|
return args.Skip(2).ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
private readonly List<string> _mergeHelperSessionDirs = new();
|
private readonly List<string> _mergeHelperSessionDirs = new();
|
||||||
|
|
||||||
/// The session dir is the value right after --add-dir; register it for cleanup
|
/// The session dir is the value right after --add-dir; register it for cleanup
|
||||||
@@ -363,13 +374,14 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task BuildForMergeHelperAsync_EmptyTaskIds_ThrowsInvalidOperation()
|
public async Task BuildForMergeHelperAsync_EmptyTaskIds_ThrowsInvalidOperation()
|
||||||
{
|
{
|
||||||
|
var listId = await SeedListAsync(workingDir: _tempDir);
|
||||||
var svc = BuildService();
|
var svc = BuildService();
|
||||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
() => svc.BuildForMergeHelperAsync(Array.Empty<string>(), null, CancellationToken.None));
|
() => svc.BuildForMergeHelperAsync(Array.Empty<string>(), listId, CancellationToken.None));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task BuildForMergeHelperAsync_NoExistingWorkingDirs_ThrowsInvalidOperation()
|
public async Task BuildForMergeHelperAsync_ListWithoutExistingWorkingDir_ThrowsInvalidOperation()
|
||||||
{
|
{
|
||||||
var listId = await SeedListAsync(workingDir: Path.Combine(_tempDir, "gone"));
|
var listId = await SeedListAsync(workingDir: Path.Combine(_tempDir, "gone"));
|
||||||
var taskId = Guid.NewGuid().ToString();
|
var taskId = Guid.NewGuid().ToString();
|
||||||
@@ -377,53 +389,56 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
|||||||
|
|
||||||
var svc = BuildService();
|
var svc = BuildService();
|
||||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
() => svc.BuildForMergeHelperAsync(new[] { taskId }, null, CancellationToken.None));
|
() => svc.BuildForMergeHelperAsync(new[] { taskId }, listId, CancellationToken.None));
|
||||||
Assert.Contains("working directory", ex.Message);
|
Assert.Contains("working directory", ex.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task BuildForMergeHelperAsync_TasksAcrossTwoRepos_BuildsGlobalScopeSpec()
|
public async Task BuildForMergeHelperAsync_UnknownList_Throws()
|
||||||
{
|
{
|
||||||
var repoA = Path.Combine(_tempDir, "repoA");
|
var listId = await SeedListAsync(workingDir: _tempDir);
|
||||||
var repoB = Path.Combine(_tempDir, "repoB");
|
var taskId = Guid.NewGuid().ToString();
|
||||||
Directory.CreateDirectory(repoA);
|
await SeedTaskAsync(taskId, listId, TaskStatus.Idle);
|
||||||
Directory.CreateDirectory(repoB);
|
|
||||||
|
|
||||||
var listA = await SeedListAsync(workingDir: repoA, name: "Alpha");
|
|
||||||
var listB = await SeedListAsync(workingDir: repoB, name: "Beta");
|
|
||||||
var t1 = Guid.NewGuid().ToString();
|
|
||||||
var t2 = Guid.NewGuid().ToString();
|
|
||||||
var t3 = Guid.NewGuid().ToString();
|
|
||||||
await SeedTaskAsync(t1, listA, TaskStatus.WaitingForReview, title: "First task");
|
|
||||||
await SeedTaskAsync(t2, listB, TaskStatus.Idle, title: "Second task");
|
|
||||||
await SeedTaskAsync(t3, listA, TaskStatus.Failed, title: "Third task"); // same repo as t1 -> distinct
|
|
||||||
|
|
||||||
var svc = BuildService();
|
var svc = BuildService();
|
||||||
var spec = await svc.BuildForMergeHelperAsync(new[] { t1, t2, t3 }, null, CancellationToken.None);
|
await Assert.ThrowsAsync<KeyNotFoundException>(
|
||||||
|
() => svc.BuildForMergeHelperAsync(new[] { taskId }, "no-such-list", CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BuildForMergeHelperAsync_BuildsListScopedSpecWithSingleRepo()
|
||||||
|
{
|
||||||
|
var repo = Path.Combine(_tempDir, "repoOnly");
|
||||||
|
Directory.CreateDirectory(repo);
|
||||||
|
|
||||||
|
var listId = await SeedListAsync(workingDir: repo, name: "Alpha");
|
||||||
|
var t1 = Guid.NewGuid().ToString();
|
||||||
|
var t2 = Guid.NewGuid().ToString();
|
||||||
|
await SeedTaskAsync(t1, listId, TaskStatus.WaitingForReview, title: "First task");
|
||||||
|
await SeedTaskAsync(t2, listId, TaskStatus.Idle, title: "Second task");
|
||||||
|
|
||||||
|
var svc = BuildService();
|
||||||
|
var spec = await svc.BuildForMergeHelperAsync(new[] { t1, t2 }, listId, CancellationToken.None);
|
||||||
var sessionDir = TrackSessionDir(spec);
|
var sessionDir = TrackSessionDir(spec);
|
||||||
|
|
||||||
// cwd: no listId -> first existing repo dir.
|
Assert.Equal(repo, spec.Cwd);
|
||||||
Assert.Equal(repoA, spec.Cwd);
|
|
||||||
Assert.Equal(_claudeStubPath, spec.Exe);
|
Assert.Equal(_claudeStubPath, spec.Exe);
|
||||||
|
|
||||||
var args = spec.Args.ToList();
|
var args = spec.Args.ToList();
|
||||||
|
|
||||||
// --permission-mode default
|
|
||||||
var pmIdx = args.IndexOf("--permission-mode");
|
var pmIdx = args.IndexOf("--permission-mode");
|
||||||
Assert.True(pmIdx >= 0);
|
Assert.True(pmIdx >= 0);
|
||||||
Assert.Equal("default", args[pmIdx + 1]);
|
Assert.Equal("default", args[pmIdx + 1]);
|
||||||
|
|
||||||
// allowedTools string for the merge helper
|
|
||||||
var atIdx = args.IndexOf("--allowedTools");
|
var atIdx = args.IndexOf("--allowedTools");
|
||||||
Assert.Equal("mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill", args[atIdx + 1]);
|
Assert.Equal("mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill", args[atIdx + 1]);
|
||||||
|
|
||||||
// --add-dir: session dir + BOTH distinct repo dirs (repoA only once)
|
// --add-dir: session dir + the list's single repo dir
|
||||||
var addIdx = args.IndexOf("--add-dir");
|
var addIdx = args.IndexOf("--add-dir");
|
||||||
var appendIdx = args.IndexOf("--append-system-prompt-file");
|
var appendIdx = args.IndexOf("--append-system-prompt-file");
|
||||||
var addDirs = args.GetRange(addIdx + 1, appendIdx - addIdx - 1);
|
var addDirs = args.GetRange(addIdx + 1, appendIdx - addIdx - 1);
|
||||||
Assert.Equal(new[] { sessionDir, repoA, repoB }, addDirs);
|
Assert.Equal(new[] { sessionDir, repo }, addDirs);
|
||||||
|
|
||||||
// system prompt file follows --append-system-prompt-file
|
|
||||||
var systemPromptPath = args[appendIdx + 1];
|
var systemPromptPath = args[appendIdx + 1];
|
||||||
Assert.Equal(Path.Combine(sessionDir, "system-prompt.md"), systemPromptPath);
|
Assert.Equal(Path.Combine(sessionDir, "system-prompt.md"), systemPromptPath);
|
||||||
Assert.True(File.Exists(systemPromptPath));
|
Assert.True(File.Exists(systemPromptPath));
|
||||||
@@ -434,43 +449,32 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
|||||||
Assert.Contains(briefPath, kickoff);
|
Assert.Contains(briefPath, kickoff);
|
||||||
Assert.DoesNotContain('\n', kickoff);
|
Assert.DoesNotContain('\n', kickoff);
|
||||||
|
|
||||||
// brief exists, carries the scope label and every task's title
|
|
||||||
Assert.True(File.Exists(briefPath));
|
|
||||||
var brief = File.ReadAllText(briefPath);
|
|
||||||
Assert.Contains("Scope: All lists", brief);
|
|
||||||
Assert.Contains("First task", brief);
|
|
||||||
Assert.Contains("Second task", brief);
|
|
||||||
Assert.Contains("Third task", brief);
|
|
||||||
|
|
||||||
Assert.Equal("200000", spec.Env["MCP_TOOL_TIMEOUT"]);
|
Assert.Equal("200000", spec.Env["MCP_TOOL_TIMEOUT"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task BuildForMergeHelperAsync_WithListId_UsesListWorkingDirAsCwdAndListScope()
|
public async Task BuildForMergeHelperAsync_BriefNamesListRepoAndEveryTask()
|
||||||
{
|
{
|
||||||
var repoA = Path.Combine(_tempDir, "repoA2");
|
var repo = Path.Combine(_tempDir, "repoBrief");
|
||||||
var repoB = Path.Combine(_tempDir, "repoB2");
|
Directory.CreateDirectory(repo);
|
||||||
Directory.CreateDirectory(repoA);
|
|
||||||
Directory.CreateDirectory(repoB);
|
|
||||||
|
|
||||||
var listA = await SeedListAsync(workingDir: repoA, name: "Alpha");
|
var listId = await SeedListAsync(workingDir: repo, name: "Alpha");
|
||||||
var listB = await SeedListAsync(workingDir: repoB, name: "Beta");
|
|
||||||
var t1 = Guid.NewGuid().ToString();
|
var t1 = Guid.NewGuid().ToString();
|
||||||
var t2 = Guid.NewGuid().ToString();
|
var t2 = Guid.NewGuid().ToString();
|
||||||
await SeedTaskAsync(t1, listA, TaskStatus.WaitingForReview, title: "Task in Alpha");
|
await SeedTaskAsync(t1, listId, TaskStatus.WaitingForReview, title: "First task");
|
||||||
await SeedTaskAsync(t2, listB, TaskStatus.WaitingForReview, title: "Task in Beta");
|
await SeedTaskAsync(t2, listId, TaskStatus.Idle, title: "Second task");
|
||||||
|
|
||||||
var svc = BuildService();
|
var svc = BuildService();
|
||||||
// Scope on listB even though listA's task comes first: cwd must be listB's repo.
|
var spec = await svc.BuildForMergeHelperAsync(new[] { t1, t2 }, listId, CancellationToken.None);
|
||||||
var spec = await svc.BuildForMergeHelperAsync(new[] { t1, t2 }, listB, CancellationToken.None);
|
|
||||||
var sessionDir = TrackSessionDir(spec);
|
var sessionDir = TrackSessionDir(spec);
|
||||||
|
|
||||||
Assert.Equal(repoB, spec.Cwd);
|
|
||||||
|
|
||||||
var brief = File.ReadAllText(Path.Combine(sessionDir, "brief.md"));
|
var brief = File.ReadAllText(Path.Combine(sessionDir, "brief.md"));
|
||||||
Assert.Contains("Scope: List: Beta", brief);
|
Assert.Contains("Scope: List: Alpha", brief);
|
||||||
Assert.Contains("Task in Alpha", brief);
|
Assert.Contains($"Repo: {repo}", brief);
|
||||||
Assert.Contains("Task in Beta", brief);
|
Assert.Contains("First task", brief);
|
||||||
|
Assert.Contains("Second task", brief);
|
||||||
|
Assert.Contains(t1, brief);
|
||||||
|
Assert.Contains(t2, brief);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -505,11 +509,12 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
|||||||
|
|
||||||
var spec = BuildService().BuildPlanningResume(ctx);
|
var spec = BuildService().BuildPlanningResume(ctx);
|
||||||
|
|
||||||
Assert.Equal("--permission-mode", spec.Args[0]);
|
var args = ArgsAfterEffort(spec);
|
||||||
Assert.Equal("default", spec.Args[1]);
|
Assert.Equal("--permission-mode", args[0]);
|
||||||
Assert.Equal("--allowedTools", spec.Args[2]);
|
Assert.Equal("default", args[1]);
|
||||||
Assert.Contains("mcp__claudedo__", spec.Args[3]);
|
Assert.Equal("--allowedTools", args[2]);
|
||||||
Assert.Equal(new[] { "--resume", "sess-42" }, spec.Args.Skip(4).ToArray());
|
Assert.Contains("mcp__claudedo__", args[3]);
|
||||||
|
Assert.Equal(new[] { "--resume", "sess-42" }, args.Skip(4).ToArray());
|
||||||
Assert.Equal("tok-2", spec.Env["CLAUDEDO_PLANNING_TOKEN"]);
|
Assert.Equal("tok-2", spec.Env["CLAUDEDO_PLANNING_TOKEN"]);
|
||||||
Assert.Equal(_worktreeDir, spec.Cwd);
|
Assert.Equal(_worktreeDir, spec.Cwd);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,8 @@ public sealed class TaskStateServiceTests : IDisposable
|
|||||||
string? parentId = null,
|
string? parentId = null,
|
||||||
int sortOrder = 0,
|
int sortOrder = 0,
|
||||||
string? blockedBy = null,
|
string? blockedBy = null,
|
||||||
PlanningPhase phase = PlanningPhase.None)
|
PlanningPhase phase = PlanningPhase.None,
|
||||||
|
bool isManual = false)
|
||||||
{
|
{
|
||||||
var id = Guid.NewGuid().ToString();
|
var id = Guid.NewGuid().ToString();
|
||||||
await using var ctx = _factory.CreateDbContext();
|
await using var ctx = _factory.CreateDbContext();
|
||||||
@@ -56,6 +57,7 @@ public sealed class TaskStateServiceTests : IDisposable
|
|||||||
ParentTaskId = parentId,
|
ParentTaskId = parentId,
|
||||||
SortOrder = sortOrder,
|
SortOrder = sortOrder,
|
||||||
BlockedByTaskId = blockedBy,
|
BlockedByTaskId = blockedBy,
|
||||||
|
IsManual = isManual,
|
||||||
});
|
});
|
||||||
await ctx.SaveChangesAsync();
|
await ctx.SaveChangesAsync();
|
||||||
return id;
|
return id;
|
||||||
@@ -89,6 +91,30 @@ public sealed class TaskStateServiceTests : IDisposable
|
|||||||
Assert.Contains(_built.Hub.Proxy.Calls, c => c.Method == "TaskUpdated");
|
Assert.Contains(_built.Hub.Proxy.Calls, c => c.Method == "TaskUpdated");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EnqueueAsync_ManualTask_Rejected_AndStaysIdle()
|
||||||
|
{
|
||||||
|
var id = await SeedTaskAsync(TaskStatus.Idle, isManual: true);
|
||||||
|
|
||||||
|
var result = await _sut.EnqueueAsync(id, default);
|
||||||
|
|
||||||
|
Assert.False(result.Ok);
|
||||||
|
Assert.Contains("Manual task", result.Reason);
|
||||||
|
Assert.Equal(TaskStatus.Idle, await GetStatusAsync(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StartRunningAsync_ManualTask_Rejected_AndStaysIdle()
|
||||||
|
{
|
||||||
|
var id = await SeedTaskAsync(TaskStatus.Idle, isManual: true);
|
||||||
|
|
||||||
|
var result = await _sut.StartRunningAsync(id, DateTime.UtcNow, default);
|
||||||
|
|
||||||
|
Assert.False(result.Ok);
|
||||||
|
Assert.Contains("Manual task", result.Reason);
|
||||||
|
Assert.Equal(TaskStatus.Idle, await GetStatusAsync(id));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task EnqueueAsync_FromRunning_Rejects_AndDoesNotMutate()
|
public async Task EnqueueAsync_FromRunning_Rejects_AndDoesNotMutate()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ sealed class FakeWorkerClient : IWorkerClient
|
|||||||
public Task SubmitTaskForReviewAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
public Task SubmitTaskForReviewAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
||||||
public Task<LaunchSpec> GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default)
|
public Task<LaunchSpec> GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default)
|
||||||
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
|
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
|
||||||
public Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string? listId, CancellationToken ct = default)
|
public Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, CancellationToken ct = default)
|
||||||
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
|
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
|
||||||
public Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
|
public Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
|
||||||
=> Task.FromResult(new LaunchSpec(directory, "claude", Array.Empty<string>(), new Dictionary<string, string>()));
|
=> Task.FromResult(new LaunchSpec(directory, "claude", Array.Empty<string>(), new Dictionary<string, string>()));
|
||||||
|
|||||||
Reference in New Issue
Block a user