docs(specs): add task-numbers and dependency-chain display designs

This commit is contained in:
mika kuns
2026-08-11 09:56:26 +02:00
parent d885b4eb41
commit 31a9e87b56
2 changed files with 189 additions and 0 deletions
@@ -0,0 +1,73 @@
# Dependency chain display — Design
**Date:** 2026-08-11
**Status:** approved, not implemented
## Problem
`DependsOnTaskId` is invisible in the UI. `TaskRowViewModel` has no `DependsOnTaskId` property
and `TaskRowView.axaml` renders nothing for it — a chained task looks exactly like an unrelated
one. The execution order the user declared is not readable anywhere in the list.
## Target
A chain renders as a group: the head full width, its dependents indented behind a vertical rail,
each carrying a small circular step badge on the rail.
```
┌──────────────────────────────────────────┐
│ chain head (full width, no badge) │
└──────────────────────────────────────────┘
╷ ┌────────────────────────────────────┐
(1)│ first dependent │
╷ └────────────────────────────────────┘
╷ ┌────────────────────────────────────┐
(2)│ second │
╷ └────────────────────────────────────┘
╷ ┌────────────────────────────────────┐
(2)│ parallel to the second │
╷ └────────────────────────────────────┘
╷ ┌────────────────────────────────────┐
(3)│ third │
└────────────────────────────────────┘
```
## Model reading
- **Step number = hop distance from the chain head**, not a running counter. `DependsOnTaskId` is
a single FK, so several tasks may share one predecessor → same depth → **same number**. Two
rows showing `2` means "these two are both unblocked by step 1", which is the intended reading.
- **Chain head** = a task with no `DependsOnTaskId` that at least one other task points at. A task
with no dependents and no dependency is not a chain and renders exactly as today.
- Cycles are impossible (`TaskStateService.SetDependsOnAsync` rejects them), so the depth walk
always terminates. Still cap the walk defensively.
## Decisions
**One indent level only — parent wins.** The 24 px indent track already exists for planning
children (`TaskRowView.axaml:22-28`, gated on `ShowAsChild`). A planning child that *also* has a
`DependsOnTaskId` keeps the parent indent and shows its chain membership as a small inline
chip (`after #123`) instead of a second indent level. No nesting, no 48 px rows.
**The group is pulled together; the head carries it.** Chain members are re-ordered to sit
directly under their head regardless of `SortOrder`, so displayed order always equals execution
order. Dragging the head moves the whole group; members are not individually draggable.
**Head not in view → no orphan rails.** Exact precedent exists: `ParentInView` /`ShowAsChild`
(`TaskRowViewModel.cs:75`, computed in `Regroup` at `TasksIslandViewModel.cs:543`). Same
treatment — if the head is filtered out (other list, My Day, completed group), the row renders
flat with the `after #123` chip instead of a dangling rail.
**Badge has no `#`.** If task numbers (`2026-08-11-task-numbers-design.md`) also land, a row
would show a rail badge and a `#412` title prefix. The rail badge is a step position, not an
identity — render it bare (`2`), never `#2`.
## Slices
| # | Slice | Depends on |
|---|---|---|
| 1 | VM: `DependsOnTaskId` + depth/head computation in `Regroup`, group pull-together, drag semantics | — |
| 2 | View: rail, step badge, `after …` chip, locale keys | 1 |
Slice 1 exposes the contract Slice 2 binds to: `ShowAsChainMember`, `ChainStep`,
`ChainAfterLabel`.
@@ -0,0 +1,116 @@
# Task Numbers (`#123`) — Design
**Date:** 2026-08-11
**Status:** approved, not implemented
## Problem
Tasks are only addressable by GUID. Every report from Claude reads
"task `0ef5ga…` is done", which is unreadable and untraceable for the user. A short,
stable, human-speakable handle is needed.
## Decision
Add a **global, monotonically increasing integer** `TaskEntity.Number`, displayed as `#123`.
- **Global, not per list.** Per-list numbering would make `#123` ambiguous across the ~15
lists and would force a list argument into every lookup. Global costs nothing and is
unambiguous.
- **Alias, not identity.** The GUID stays the primary key and stays in branch names
(`claudedo/{id}`), worktree paths, and the `ClaudeDo-Task:` commit trailer. `Number` is
purely a display + lookup alias.
- **Immutable, never reused.** A number that appeared in a log must never later point at a
different task. Deleting tasks leaves gaps — that is correct and intended.
## Allocation
`MAX(number) + 1` is **wrong**: deleting the newest task frees its number for reuse.
Use a persistent counter instead.
- `app_settings.next_task_number` (INTEGER NOT NULL, singleton row — same table as
`MaxTurnsCeiling` etc.).
- Allocation is one statement, atomic under SQLite's single-writer model:
`UPDATE app_settings SET next_task_number = next_task_number + 1
WHERE id = 1 RETURNING next_task_number - 1`
executed in the **same transaction** as the task insert.
- `tasks.number` gets a **UNIQUE index** (`idx_tasks_number`) so a collision is a DB error,
not silent corruption. On a unique violation, retry the allocation (bounded, 5 attempts).
Only **two** insert sites exist and both must allocate — every other creation path
(UI, `add_task`, `batch_add_tasks`, Online Inbox sync, merge-helper handler task,
planning children) routes through one of them:
- `TaskRepository.AddAsync` (`src/ClaudeDo.Data/Repositories/TaskRepository.cs:20`)
- `TaskRepository.AddChildAsync` (same file, ~line 297)
Do **not** reuse `SortOrder` — it is per list and mutable via drag & drop.
## Migration + backfill
One migration, created **alone** (never in parallel with another migration — sibling
migrations off the same parent silently drop each other's columns on a SQLite table rebuild):
1. `ALTER TABLE tasks ADD COLUMN number INTEGER NOT NULL DEFAULT 0`
2. Backfill in creation order:
`UPDATE tasks SET number = (SELECT COUNT(*) FROM tasks t2
WHERE t2.created_at < tasks.created_at OR (t2.created_at = tasks.created_at AND t2.id <= tasks.id))`
(or the equivalent `ROW_NUMBER()` window function).
3. `ALTER TABLE app_settings ADD COLUMN next_task_number INTEGER NOT NULL DEFAULT 1`
then set it to `(SELECT COALESCE(MAX(number), 0) + 1 FROM tasks)`.
4. Create the unique index **after** the backfill.
⚠️ Tests use `EnsureCreated`, which bypasses migrations — the backfill needs a test that
explicitly runs `Migrate()` against a DB seeded with pre-migration rows.
## Resolution: `#123` → GUID
New `TaskIdResolver` in `src/ClaudeDo.Worker/External/`:
- Input `#123` or bare `123` (a GUID is never all-digits, so this is unambiguous) → look up
by number.
- Anything else → passed through as a GUID.
- Unknown number → a clear MCP error (`no task with number 123`), never a silent null.
Wired at the top of every MCP tool taking a task id (~44 parameters across `External/`),
including the id arrays of the `batch_*` tools.
## Output surface
Two central mappers cover most tools:
- `ExternalMcpService.ToDto` (line ~1629) → `TaskDto`
- `ExternalMcpService.GetTaskRefAsync` (line ~335) → `TaskRefDto`
Add `Number` to both. Then the DTOs that carry a bare task id and bypass those mappers:
`RunTaskNowResult`, `MergeTaskResultDto`, `PossibleDuplicateDto`, `SubsetRelationDto`,
`FileOverlapDto`, the `BatchMcpTools` results, `TaskWaitMcpTools`, `QueueStateMcpTools`,
`RunHistoryMcpTools`.
`McpToolDocs` gets a shared clause instructing the agent to **refer to tasks as `#<number>`
when reporting to the user**. Without this the number is present in the payload but never
spoken — this clause is the actual point of the feature.
## UI surface
- `TaskRowViewModel.Number` → dim `#123` in the row, tokenized (no inline literals).
- Detail pane header.
- `HubBroadcaster.WorkerLog` business events in `TaskRunner` / `TaskMergeService` /
`TaskResetService` say `#123` instead of the GUID.
## Out of scope (possible follow-up)
Branch names and worktree folder names (`claudedo/123-<slug>`, `123-<slug>`). Cheap once
numbers exist, but a separate change — it touches merge, self-heal, and the merge helper.
## Slices
| # | Slice | Depends on |
|---|---|---|
| 1 | Schema, counter, allocator, backfill migration + tests | — |
| 2 | MCP output: `Number` in all task-carrying DTOs | 1 |
| 3 | MCP input: `TaskIdResolver` + `McpToolDocs` reporting clause | 2 |
| 4 | UI: row/detail display + worker-log messages | 1 |
| 5 | Docs: `CLAUDE.md` (Data, Worker), explore-notes | 3, 4 |
2 and 3 both edit `ExternalMcpService.cs` heavily, so 3 waits for 2 rather than running
beside it. 4 touches disjoint files and can run beside 2.