AW-02 — Task board · Task breakdown
Ordered, executable tasks derived from
plan.md. Each carries explicit file paths and a definition of done. Every task ships with its tests (Constitution VI). Work top to bottom; tasks marked(parallel)may run alongside the task immediately above them.
Feature ID: aw-02-task-board
Spec: ./spec.md · Plan: ./plan.md
Status: Draft
Last updated: 2026-09-06
How to use
- All paths are repo-relative to the monorepo root.
- Run everything with
pnpmfrom the root unless a task says otherwise. - No task in this breakdown adds an entity, a table, a column or a migration. If
a task appears to need one, stop and re-read
plan.md§3 — the signal it wants already exists. The single exception is deferred to T39 and gated on a product decision. - Add new tasks at the bottom; never renumber.
- Commit style:
feat(tasks): …,test(tasks): …,chore(i18n): ….
The three standing constraints
Re-read these before every task. They are what makes this epic additive.
TasksKanbanView.tsxkeeps its export and its props. Every existing caller —TasksList,TasksScopedSection, and through them/tasks,/missions/[id]/tasks,/works/[id]/tasks,/ideas/[id]/tasks— must keep compiling and keep working with no change on their side.GET /api/tasksdoes not change. Its existing controller specs must pass untouched. Any diff in them means an additive promise was broken.- Every new default that changes what a user sees has a one-action toggle back. Board-as-landing-view, top-level-only, templates-out-of-columns.
PHASE 1 — Make the board true
P1.A — Pure domain logic (no I/O, no framework)
-
T1. Column tables and drop resolution. - Create
packages/agent/src/tasks-domain/task-board-columns.tsexporting: -export type BoardLayout = 'status' | 'focus';-export interface BoardColumnDef { key: string; statuses: readonly TaskStatus[]; terminal: boolean; }-STATUS_COLUMNS: readonly BoardColumnDef[]— seven entries, one status each, in the orderbacklog, todo, in_progress, in_review, blocked, done, cancelled. -FOCUS_COLUMNS: readonly BoardColumnDef[]—backlog(backlog,todo),in_flight(in_progress),needs_you(in_review,blocked),done(done), plus acancelledentry marked as toggle-only. -columnsFor(layout, opts: { includeCancelled: boolean }): BoardColumnDef[]-columnForStatus(layout, status): string-resolveDrop(from: TaskStatus, column: BoardColumnDef, allowed: Record<TaskStatus, TaskStatus[]>): { kind: 'apply'; to: TaskStatus } | { kind: 'ask'; options: TaskStatus[] } | { kind: 'refuse' }- No imports from TypeORM, NestJS, or any service.TaskStatusis imported as a type only. - Add a file-header comment stating the invariant: everyTaskStatusvalue appears in exactly one column of each layout; a column is never derived from anything but status. - Test:packages/agent/src/tasks-domain/__tests__/task-board-columns.spec.ts- Both layouts cover all seven statuses exactly once (assert by set equality againstObject.values(TaskStatus), so adding a status to the enum without adding a column fails the suite). -resolveDropover the full 7 × 5 matrix, asserting the verdict for every pair. -in_progress → needs_youis the onlyaskin the whole matrix. - Every drop out ofcancelledisrefuse. - Done when:pnpm --filter @ever-works/agent test task-board-columnsis green and the file imports nothing outsideentities/task.entity. -
T2. The stall predicate. (parallel with T1)
- Create
packages/agent/src/tasks-domain/task-board-stall.tsexporting:export const DEFAULT_STALL_AFTER_DAYS = 2;clampStallAfterDays(value: number | null | undefined): number— 1..30, default 2.stallCutoff(now: Date, days: number): DateisStalled(input: { status; latestRunStatus; updatedAt; now; stallAfterDays }): booleanexactly asplan.md§2.4.
- Add the header comment stating what the predicate costs: it derives "no
progress" from
updatedAt, so an unrelated edit clears the flag. It under-reports and never over-reports, and that asymmetry is why no new column is stored. - Test:
.../__tests__/task-board-stall.spec.ts— 47 h vs 49 h at the default threshold;latestRunStatusofqueuedandrunning(not stalled) vs each terminal value andnull(stalled); every non-in_progressstatus (never stalled); the clamp at0,1,30,31,null,undefined,NaN. - Done when: green, and the file has no framework import.
- Create
-
T3. Provenance ordering. (parallel with T1)
- Create
packages/agent/src/tasks-domain/task-board-provenance.tsexporting:export type ProvenanceKind = 'trigger' | 'recurringTemplate' | 'scheduled' | 'mission' | 'idea' | 'work' | 'team' | 'goal' | 'agent' | 'raisedByAgent' | 'delegated' | 'creator';PROVENANCE_PRECEDENCE: readonly ProvenanceKind[]in spec FR-28's order.orderProvenance(entries: ProvenanceEntry[]): ProvenanceEntry[]— sorted by precedence, with entries whosenameisnulldropped (spec FR-29).
- Test:
.../__tests__/task-board-provenance.spec.ts— all twelve present returns all twelve in precedence order; an unresolvable name is dropped, not rendered as an id; an empty input returns empty. - Done when: green.
- Create
-
T4. Export the new module surface.
- Modify
packages/agent/src/tasks-domain/index.ts: re-export T1, T2 and T3. - Done when:
apps/apiandapps/webcan both import them, andpnpm --filter @ever-works/agent buildis clean.
- Modify
P1.B — Filter options (additive, no schema)
- T5. Three optional fields on
ListTasksFilter.- Modify
packages/agent/src/database/repositories/task.repository.ts:- Widen
parentTaskId?: stringtoparentTaskId?: string | 'none'. - Add
isRecurring?: boolean. - Add
orderBy?: 'updatedAt' | 'priorityThenUpdated' | 'stalledThenPriority'. - Add
stallCutoff?: Date(only read whenorderByis'stalledThenPriority').
- Widen
- In
list():parentTaskId === 'none'→andWhere('task.parentTaskId IS NULL'); a uuid keeps today's meaning;undefinedadds no predicate.isRecurringdefined →andWhere('task.isRecurring = :isRecurring', …);undefinedadds no predicate.- Replace the single
qb.orderBy('task.updatedAt', 'DESC')with a switch whoseundefinedand'updatedAt'branches emit the identical clause. 'stalledThenPriority'emits the three-keyORDER BYofplan.md§3.2.
- Add the comment explaining why
task.priority ASCis correct: the column is avarchar(4)holdingp0–p4, so lexicographic order is priority order. Without that note it reads as an accident and someone will "fix" it. - Test:
.../__tests__/task-repository-board-filters.spec.ts— assert the generated SQL for each option, and one regression case asserting that omitting all four new fields produces the exact SQL the repository produces today. - Done when: green, and every existing
task.repositorytest passes untouched.
- Modify
P1.C — The board read model
-
T6.
TaskBoardService— counts, cards and templates.- Create
packages/agent/src/tasks-domain/task-board.service.ts. - Injects the task repository and the ownership-scope helper from
packages/agent/src/database/ownership-scope.ts. getBoard(userId, input: BoardInput, scope): Promise<BoardResult>runs exactly the three queries ofplan.md§2.1:- Q1 grouped
COUNT(*) … GROUP BY statusunder the shared predicate → the true per-status totals. - Q2 per-column top-N rows via the repository's
list()withorderBy: 'stalledThenPriority',includeRun: true. - Q3 recurring templates:
isRecurring: trueordered bynextOccurrenceAt.
- Q1 grouped
- The predicate for Q1 and Q2 is built once by a private
buildBoardFilter(input)and passed to both, so a count and its cards can never disagree. Assert this in the test. - Terminal columns (
done,cancelled) getupdatedAt >= now - terminalWindowDaysapplied to both Q1 and Q2. - Defaults:
layout: 'status',columnLimit: 50(clamped 1..100),terminalWindowDays: 7(clamped 1..90), sub-tasks excluded (parentTaskId: 'none'), templates excluded (isRecurring: false), hidden excluded. getColumn(userId, input, columnKey, offset, scope)returns one column only.- Test:
.../__tests__/task-board.service.spec.tstotalcomes from Q1 and is notcards.length— seed 140 rows in one status withcolumnLimit: 50and asserttotal === 140,cards.length === 50.- Every toggle flips exactly one predicate.
- The terminal window applies to the count as well as the cards.
getColumnreturns the same rows Q2 would have returned at that offset.
- Done when: green and the service has no enrichment code in it yet.
- Create
-
T7. Wire the service into DI.
- Modify
packages/agent/src/tasks-domain/tasks.module.ts— provide and exportTaskBoardService. - Done when:
apps/api/src/tasks/tasks.module.tsresolves it, andtasks.module.di-contract.spec.tsis extended with the new provider and passes.
- Modify
P1.D — API surface
-
T8.
GET /api/tasks/board. - Modifyapps/api/src/tasks/tasks.controller.ts. Place the route above@Get(':id')— the existing file already warns that a later static segment is shadowed by the param route, andrun-batchcarries that comment; follow it. - Declare every query parameter with an explicit@ApiQuery({ required: false }). The file's own comment explains why: without the CLI plugin, a bare@Query('x') x?: stringis emitted as required and the MCP tool schema then forces every filter. - Parameters and defaults exactly asplan.md§4.1. Reuse the controller's existingparsePriorityListhelper; do not write a second parser. - Throttle: match the existing list route. - Ownership:@CurrentUser()+this.scopeContext.getScope(), same as every neighbour. - Test:apps/api/src/tasks/tasks.controller.board.spec.ts— defaults;layout=focusreturns four columns;columnLimitclamps at 1 and 100;terminalWindowDaysclamps at 1 and 90; eachinclude*flag flips one predicate; malformed values fall back to the default rather than 500ing. - Done when: green andtasks.controller.scope.spec.tsandtasks.controller.board-visibility.spec.tsstill pass unmodified. -
T9.
GET /api/tasks/board/column.- Same file, same placement rule. Adds
status(one column key, validated againstcolumnsFor()) andoffset. - Returns
{ key, statuses, total, cards }. - Test: extend
tasks.controller.board.spec.ts— an unknown column key is a 400, not a 500; the offset pages within that column only. - Done when: green.
- Same file, same placement rule. Adds
-
T10. Board scope isolation spec.
- Create
apps/api/src/tasks/tasks.controller.board-scope.spec.ts, modelled on the existingtasks.controller.scope.spec.ts. - Assert: another user's Task appears in no column and in no count; a Task in
another Organization scope likewise;
board/columnfor a foreign scope returns the same empty shape rather than leaking a total. - Done when: green. This spec is the guard on spec FR-65 and FR-66 and must not be skipped to a later phase.
- Create
-
T11. Extend the existing board-visibility spec.
- Modify
apps/api/src/tasks/tasks.controller.board-visibility.spec.ts: add cases provinghiddenFromBoardrows are absent from the board read's columns and its counts, and present underincludeHidden=true— the file already covers the list route; this extends it to the board route rather than starting a parallel file. - Done when: green.
- Modify
-
T12. Typed web client.
- Modify
apps/web/src/lib/api/tasks.ts: addtasksAPI.board(input)andtasksAPI.boardColumn(input)plus theBoardResult/BoardColumn/BoardCardtypes. Do not change the existingTasktype — theboardblock is an additive optional field on it. - Done when:
pnpm --filter @ever-works/web type-checkis clean.
- Modify
-
T13. Server actions.
- Modify
apps/web/src/app/actions/tasks.ts: appendgetTaskBoardAction,getTaskBoardColumnAction,setTasksViewAction. Change nothing that is already there. - Done when: the existing actions' specs pass untouched.
- Modify
P1.E — Web: extract, then improve
-
T14. Extract the card, the column and the shell — behaviour identical.
- Create
apps/web/src/components/tasks/board/:TaskBoardCard.tsx— liftTaskKanbanCardverbatim, including therhandler's four guards (modifier, repeat, text input, open diff sheet), the drag handlers,RunWithAgentMenu,TaskBranchChip,TaskPrPill,TaskRunChip,GateChip,TaskDiffSheet, and the per-card error line.TaskBoardColumn.tsx— liftTaskKanbanColumnverbatim, includingRUN_ALL_MAX = 20, therunAllEligiblerule, and the batch summary.TaskBoard.tsx— lift theTasksKanbanViewbody verbatim, includinguseTaskRunPollingand its merge rules, the optimistic move with rollback, and the post-drop agent-picker logic.
TasksKanbanView.tsxbecomes a thin adapter renderingTaskBoardfrom a plainTask[], keeping its export name and prop shape (standing constraint 1).- This task changes no behaviour. Commit it on its own so the diff of T15 onward is readable.
- Test: the existing e2e specs that exercise the board must pass with no edit.
- Done when: green with a zero-behaviour-change diff.
- Create
-
T15. True totals and independent per-column paging.
TaskBoardColumntakestotalfrom the server rather thantasks.length, and its Show more callsgetTaskBoardColumnActionand appends to that column's array only.- Delete the client-side
MAX_VISIBLEslice; the server'scolumnLimitowns it. - Test (e2e): seed 120 Tasks across seven statuses; assert each header total matches the seed and that the largest column renders 50 cards; assert Show 50 more leaves the other columns' rendered counts and scroll positions unchanged.
- Done when: green.
-
T16. The board is an address.
- Modify
apps/web/src/components/tasks/TasksList.tsx: moveviewout ofuseStateinto the URL (?view=), falling back to atasks.viewcookie, then to'board'. Write the cookie throughsetTasksViewActionon change. - Modify
apps/web/src/app/[locale]/(dashboard)/tasks/page.tsx: add aresolveTasksView()helper; forview=boardcalltasksAPI.board(...), forcardsandtablekeep callingtasksAPI.list(...)unchanged. - Keep the page's existing server-rendered filter
<form>working and in sync with the board's filters (spec FR-22). - Test (e2e): no preference → board;
?view=table→ table; the choice survives a reload; a filtered board URL reproduces the same board and counts. - Test (Vitest): view resolution — URL beats cookie beats default.
- Done when: green, and the Cards and Table views render exactly as before.
- Modify
-
T17. Empty, error and loading states.
- Per-column empty copy (spec §6.9), the whole-board empty state (§6.8), the inline error panel with Try again and the Table link (§6.11), and RSC skeleton frames (§6.7).
- A single column's failed read shows the panel in that column only.
- Test (e2e): zero Tasks → the empty board, not seven empty columns; a forced read failure → the panel with the column frames intact and the page not blank.
- Done when: green.
-
T18. Explain the refusals the board already performs.
- The board already refuses illegal drops silently. Add the toast: dragging out of
cancelledexplains that a cancelled Task cannot be reopened (spec S15). - Surface the server's reason on a refused transition rather than the generic
Transition failedstring. - Test (Vitest): a rejected transition renders the server's message.
- Done when: green.
- The board already refuses illegal drops silently. Add the toast: dragging out of
P1.F — i18n (a P1 gate, not a P3 nicety)
-
T19. Move every hardcoded board string into the catalogue. - Modify
apps/web/messages/en.json: add thedashboard.tasksPage.boardparent and the leaves listed inplan.md§8. - Reuse, do not re-declare: the seven column names must read from the existingdashboard.tasksPage.status.*leaves and the five priority labels from the existingdashboard.tasksPage.priority.*leaves. Copying those strings intoboard.*is a review-blocking mistake — they are already translated across every locale. - Replace inTasksKanbanView.tsx/ the newboard/*files and inTasksList.tsx:Backlog,Todo,In Progress,In Review,Blocked,Done,Cancelled,Cards,Table,Kanban,All,Move →,Run all,Run N Task(s) in X,empty,Show N more,Preview the changes on this Task's branch,n/m started,Transition failed. - Every leaf name camelCase, no literal dot, and theboardparent added in the same change so no subtree can collapse. - Done when:grepfor each of those literals inapps/web/src/components/tasks/returns nothing, and the board renders in English through the catalogue. -
T20. Locale structure.
- Run the catalogue's existing locale-sync script, then the translation pass.
- Test (Vitest): extend the hydration spec so a missing parent key fails here rather than reddening every e2e shard.
- Done when: every locale file is structurally identical to
en.jsonand the hydration spec is green.
P1.G — Ordering
- T21. Priority ordering on the board.
TaskBoardServicepassesorderBy: 'stalledThenPriority'with astallCutofffromDEFAULT_STALL_AFTER_DAYS(the flag itself lands in P3; the ordering key is already correct and costs nothing now).- Column header tooltip:
board.sortTooltip. - Test (e2e): a
p0Task seeded with an oldupdatedAtrenders first in its column. - Done when: green.
PHASE 2 — Make the card legible
P2.A — The enrichment layer
-
T22. Batched, independently-guarded enrichment.
- Extend
packages/agent/src/tasks-domain/task-board.service.tswith a privateenrich(cards)running the six reads ofplan.md§2.1 (E1–E6). - Each read is one query over the whole page of cards —
WHERE taskId IN (…)with up to7 × columnLimitids — never one query per card. - Each read is individually
try/catch-wrapped; a failure sets the corresponding field to absent and appends its name todegraded[]. - Owner-name lookups are scope-filtered, so an unresolvable or invisible name
comes back
nulland is dropped byorderProvenance(T3). - Test: extend
task-board.service.spec.ts— assert one call per source with anINlist (not N calls); assert each source's failure degrades only itself and names itself indegraded[]; assert a scope-invisible owner yields no chip rather than an id. - Done when: green.
- Extend
-
T23. The
boardblock on the wire.- Add the additive
boardblock ofplan.md§4.1 to each card in the board response.provenancearrives already ordered and already filtered, so the client renders the first two and menus the rest without knowing the precedence rules. - Test: extend
tasks.controller.board.spec.ts— the block is present on every card;provenancerespects FR-28; the existingTaskshape is unchanged. - Done when: green.
- Add the additive
P2.B — The card
-
T24. Provenance chips.
- Create
apps/web/src/components/tasks/board/TaskProvenanceChips.tsx. Renders the first two entries; the rest go to the card menu's bottom block (spec §6.4). - Each chip links to the thing it names and applies the corresponding board filter on click.
- Test (Vitest): two chips rendered, third menued; an entry with a null name never reaches the component (it was dropped server-side) and the component tolerates it anyway.
- Test (e2e): a Mission-owned, a trigger-fired, a recurrence-cloned and a hand-filed Task each show the expected chip; clicking the Mission chip filters the board and updates every count; assert no Mission renders as a card in any column.
- Done when: green.
- Create
-
T25. Sub-task roll-up and the top-level default. (parallel with T24)
- Card shows
▣ done/totalwhen the Task has sub-tasks, linking to the parent's existing sub-task checklist. - Show sub-tasks toggle flips
includeSubtasks, restoring today's flat behaviour exactly. - A sub-task matching the active filters whose parent does not match renders as
its own card with a
Sub-task of {parent}chip (spec FR-42, S24) — implement this as a second, filter-scoped query in the service, not as a client fix-up. - Test (e2e): a parent with five sub-tasks, two done, renders one card with
2/5; the toggle restores six cards; the FR-42 case renders the extra card. - Done when: green.
- Card shows
-
T26. Decision chip and the two header counters. (parallel with T24)
- Chip with the open-decision count and an Open decision action, rendered in whatever column the Task's status puts it in.
- Header:
N waiting on you(clicking filters the board to exactly those) andN done today, counted since the viewer's local midnight. - The day boundary is an explicit input, exactly as
plan.md§4.1.1:- Create
packages/agent/src/tasks-domain/task-board-day.ts(pure, no framework import) withresolveBoardTimeZone(raw)andlocalDayWindow(now, timeZone): { since; resetsAt }, and re-export both frompackages/agent/src/tasks-domain/index.ts. GET /api/tasks/boardaccepts an optionaltimeZone(IANA name, declared with@ApiQuery({ required: false })).TaskBoardServicecountsstatus = 'done' AND completedAt >= sinceunder the shared board predicate and returnsdoneToday,timeZone,doneTodaySinceanddoneTodayResetsAtincounters— allnullwhen the zone is absent or invalid, and the count query is then not issued.apps/web/src/app/[locale]/(dashboard)/tasks/page.tsxforwards thetasks.timeZonecookie;apps/web/src/app/actions/tasks.tsappendssetTasksTimeZoneAction(timeZone)returning{ changed };TaskBoarddetects the browser zone on mount, calls the action, refreshes once only onchanged: true, and schedules one refresh atdoneTodayResetsAt(plus a re-check when the tab becomes visible again).
- Create
- The board counts and links; it renders no decision content (AW-03 owns that).
- Test:
packages/agent/src/tasks-domain/__tests__/task-board-day.spec.ts— every case inplan.md§10.1: UTC;Asia/TokyoandAmerica/Los_Angeleswhere the local and UTC dates differ; the 23-hourAmerica/New_Yorkday;America/Santiagowhere the change skips local midnight; one millisecond either side of a local midnight; invalid and absent zones. - Test: extend
task-board.service.spec.ts—completedAtone millisecond beforesinceis excluded and exactly atsinceis included; no zone →nullcounters and no count query. Extendtasks.controller.board.spec.ts—timeZonepasses through; an unknown zone is a 200 withdoneToday: null, not a 400. - Test (Vitest):
TaskBoardrefreshes once onchanged: true, never onchanged: false, once atdoneTodayResetsAt, and once on becoming visible after it;doneToday: nullrenders the placeholder, never0. - Test (e2e): a Task with an open escalation shows the chip while staying in
In progress; the counter matches; clicking it filters. With a non-UTCtimezoneId, a Task completed at that zone's local midnight is counted and one completed a millisecond earlier is not; a first visit shows the placeholder, then the count after one refresh. - Done when: green.
-
T27. Comment count and reply. (parallel with T24)
- Chip when the thread has ≥ 1 message,
99+above 99, opening the existing thread. A reply composed from the board posts through the existingPOST /api/tasks/:id/chat. - Introduce no new comment endpoint, service, entity or rate limit. If a task
here seems to need one, re-read
spec.md§5.4. - Test (e2e): posting from the board increments the chip and — when the mentioned Agent has a live run — is delivered into that run rather than starting a second, exactly as the detail page already behaves.
- Done when: green.
- Chip when the thread has ≥ 1 message,
-
T28. Untrusted text. (parallel with T24)
- Every Agent-authored string a card renders — title, branch name, label — is plain text, never markup, never auto-linked, truncated for display with the full value in the accessible name.
- Test (Vitest): a title containing markup renders as literal text.
- Done when: green.
P2.C — Recurrence
-
T29. Templates out of the columns.
- The board's default filter already excludes them (T6). Add the
⟳ Templatechip and the drag-disable for the Show templates path. - Test (e2e): a template is in no column by default; the toggle puts it back, chipped and not draggable.
- Done when: green.
- The board's default filter already excludes them (T6). Add the
-
T30. The recurring strip.
- Create
apps/web/src/components/tasks/board/TaskRecurringStrip.tsx— collapsed and expanded forms of spec §6.5. - Cadence text comes from the existing describers in
packages/agent/src/schedules/cadence.ts. Do not re-derive human-readable cron or RRULE text; there is already an implementation and a second one will drift. - Ended templates are listed as ended with their last fire (spec FR-46, S23), not omitted.
- Schedules links to the platform's existing schedules view.
- Test (e2e): the strip names each template, its cadence and its next fire; an ended template is listed as ended; the strip's failure hides the strip without putting templates back into the columns.
- Done when: green.
- Create
-
T31. Instance and scheduled chips. (parallel with T30)
- An instance carries
⟳ {template title}linking to its template; a one-shot scheduled Task carries🕑 Scheduled {when}until it fires. Both are ordinary, draggable cards. - Test (e2e): an instance is draggable and chipped; a scheduled Task is not treated as a template.
- Done when: green.
- An instance carries
-
T32. Hidden-work toggle. (parallel with T30)
- Show trigger-hidden Tasks reveals
hiddenFromBoardrows with aHiddenchip. Off by default; absent from every column and every count while off. - Test: covered by T11 server-side; add the e2e for the toggle.
- Done when: green.
- Show trigger-hidden Tasks reveals
PHASE 3 — Make it say when it is stuck
-
T33. The stalled flag on the card.
- Add
board.stalledto the response (the predicate is already in the ordering from T21) and the flag with its tooltip toTaskBoardFlags. - The client recomputes with the same
isStalled()from T2 so the flag and the ordering cannot drift. - Test (e2e): 49 h with no live run is flagged; 47 h is not; 49 h with a
running run is not; a
blockedTask at any age is not. - Done when: green.
- Add
-
T34. The stall sweep job.
- Create
packages/tasks/src/tasks/trigger/task-stall-sweep.task.tsfollowing the shape of the existingtask-recurrence-dispatcherandtask-pr-status-synctasks:schedules.task({ id, cron: '17 */6 * * *', run })spinning a transientTriggerInternalModuleNest context and closing it. - Register it in
packages/tasks/src/tasks/trigger/index.ts. - Cap rows per sweep, in the same way the mission tick caps itself.
- Done when: the task is registered and a local invocation produces notifications for seeded stalled Tasks and none for fresh ones.
- Create
-
T35. The
task_stallednotification kind.- Modify
packages/agent/src/tasks-domain/task-notification.service.ts: addtask_stalledto the existing kind→severity map (warning). No migration —NotificationCategory.TASKalready exists and the column is a freevarchar. deduplicationKey = ${taskId}:stalled:${startedAt ?? updatedAt}— this is what makes "once per stalled streak" true with no new state, because the key changes only when the Task moves.- Add
notifications.taskStalled.title/.bodytoen.jsonand run the locale-sync script. - Test: two sweeps over the same stalled Task produce one notification; a Task that moves and stalls again produces a second.
- Done when: green.
- Modify
-
T36. The Focus layout.
- Layout switcher; four columns plus the Show cancelled toggle; each column names the statuses it groups directly under its label (spec §6.6).
- Drops resolve through
resolveDrop(T1). The two-target picker of spec S16. - Test (e2e): dragging
in_progressontoNeeds youopens the picker; choosingBlockedmoves the card; cancelling changes nothing;cancelledis reachable via the toggle and is still visible without any toggle in the Status layout and in the Cards and Table views. - Done when: green.
-
T37. Keyboard navigation.
- Roving focus: one tab stop per column,
←→↑↓HomeEndwithin and between columns,Enteropens,Shift+F10opens the card menu,nand/on the board,Esccloses the topmost overlay. - The existing
rhandler is the model — it already ignores modifiers, key repeat, text inputs and the open diff sheet. Every new shortcut must apply the same four guards. - Test (e2e): the board is fully operable with no pointer; every drag action has a card-menu equivalent.
- Done when: green.
- Roving focus: one tab stop per column,
-
T38. Saved views.
- Named URLs stored with the user's existing preferences. No new entity (spec §5.4).
- Done when: a saved view restores layout, filters and toggles, and is shareable as a plain link.
-
T39. (Gated) A configurable stall threshold.
- Do not start this without a product decision on
spec.md§9. P1–P3 useDEFAULT_STALL_AFTER_DAYS = 2. - If adopted: one nullable
intcolumn onpackages/agent/src/entities/work-agent-preference.entity.ts, documented like its neighbourmissionDefaultOutstandingCap("NULL = inherit the platform default of 2; clamped 1–30 at the service layer"), plus one additive forward-only migration inapps/api/src/migrations/in the same PR, stamped1791020000000(AW-02 slot 00, README §5 rule 10). - Hand-check the generated migration: one
ADD COLUMN, noDROP, noALTER … TYPE, noNOT NULLwithout a default;downreverses only whatupadded. - This is the only migration this epic may ever produce.
- Done when: the migration applies on a fresh database and on one with data, and the generated diff is empty afterwards.
- Do not start this without a product decision on
Documentation and hygiene
-
T40. Correct the program vocabulary table.
- Modify
docs/specs/features/agent-workspace/README.md§1.1: the Task priority row readsp0 · p1 · p2 · p3. The entity and the message catalogue both carry five steps,p0–p4, withp4labelled Low. Change it top0 · p1 · p2 · p3 · p4. - Also in §3: this epic is sized
M, notL— the working board inTasksKanbanView.tsxremoves the largest chunk. - Done when: both edits land in the same PR as P1.
- Modify
-
T41. Fix the stale doc comments this epic touched. (parallel with T40)
apps/web/src/app/[locale]/(dashboard)/tasks/page.tsx— its header says "Kanban + per-target tabs land in Phase 14"; Kanban shipped.- Done when: the comment describes what the file does.
-
T42. Update the program tracker.
- Modify
docs/specs/features/agent-workspace/TRACKER.md: AW-02's spec and implementation status per phase. - Done when: the tracker reflects reality at each phase's merge.
- Modify
Definition of done
Phase 1
- Column header totals are true totals under the active filters, verified against a direct count on a 120-Task fixture.
- Columns page independently.
?view=and every filter are in the URL; the choice is remembered; the board is the default.- Cards sort
p0first within a column. - No string on the board is hardcoded, and the seven status names and five priority labels resolve from the keys that already existed.
- The Cards view, the Table view,
GET /api/tasksand its specs, and every scoped Task list are unchanged. - No entity, table, column or migration was added.
Phase 2
- Provenance chips name the right source for a Mission-raised, trigger-fired, recurrence-cloned, agent-delegated and hand-filed Task, and no Mission appears as a card.
- Sub-tasks roll up by default and flatten on one toggle.
- Recurring templates are in the strip and not in the columns, and one toggle puts them back.
- The Decision chip and the two header counters are correct, and the board renders no decision content of its own.
- The comment chip opens the existing thread; no second comment noun exists anywhere in the diff.
- Every enrichment degrades independently and names itself in
degraded[].
Phase 3
- The stalled flag matches the predicate at both boundaries and never fires outside
in_progress. - Exactly one notification per stalled streak.
- The Focus layout maps all seven statuses, asks rather than guesses on an ambiguous
drop, and leaves
cancelledreachable. - The board is fully operable by keyboard.
All phases
pnpm lint,pnpm type-check, the Jest suites inpackages/agentandapps/api, the Vitest suites inapps/web, the locale-sync check, and the Playwright specs are green.- The Constitution gate table in
spec.md§11 andplan.md§12 is still accurate. Gate V is conditional on T39:- T39 not adopted — the epic ships no entity change and no migration; gate V holds as written.
- T39 adopted — the epic ships exactly one entity column and its one additive,
forward-only migration (
1791020000000) in the same PR; gate V is satisfied by that migration, and the "no migration" wording in §11/§12 is updated in the same PR.