TASK_GROUPS_DESIGN.md32.7 KBView on GitHub
# Task Groups & the Queue — Preliminary Design

**2026-09-20 update:** the hard-pin escape hatch described below (§2's conversation hard-pin join
table, §4 routing step 1, and the `pin`/`unpin` UI, tRPC procedures, and CLI verbs) was removed
outright. A direct production query found zero rows in that join table, ever, for any user — a
real, shipped, reachable feature nobody used. The rest of this document (routing by AI classify,
virtual Misc, groups CRUD) is unaffected and still describes the live system.

Status: **In implementation.** Backend-first: data model, AI routing, groups CRUD, per-group overdue, and removal of the standalone `/tasks` route.

**Build scope (this run):** Phases 1 (schema), 2 (AI router — **inline classify on create**), 3 (groups CRUD + agent tool), 5 (overdue), 6 (**remove `/tasks`**). **Deferred to a follow-up:** the prioritized **Queue** (§5-Queue) and all **ranking** (§6 ranker, Phase 4, Phase 7) — per decision, no ranking is built yet. The Queue/ranker sections below remain as forward design, not part of this run.

## 1. Two axes: organization vs. prioritization

There are two independent things a task system has to answer, and conflating them is what makes these designs go sideways:

| Axis | Question | Mechanism | Cardinality |
| --- | --- | --- | --- |
| **Organization** | *Where is this filed?* | **Task group** (§2–§4) | 1 group per task (Misc if none) |
| **Prioritization** | *What do I do / look at next?* | **The Queue** (§5) | 1 global ordered list per user |

A task is **filed in exactly one group** *and* **appears once in the one global Queue** at a computed rank. Groups are the customizable filing cabinet; the Queue is the single "do-next" stream that cuts across all of it. Everything below hangs off this split.

Alongside `conversationId` (the CRM deal/person a task is *about*, unchanged) and `taskType` (the fixed action-kind enum, unchanged), `taskGroupId` is the **user-owned** organizational axis.

### Requirements

- Every task is filed into **one** group by an **AI router** at creation (§4). No group ⇒ implicitly **Misc**.
- Groups are fully user-defined: name + natural-language criteria the router reads. Nothing is hardcoded.
- The Queue is a single per-user ordered list of **attention items** — open *tasks* to do **and** *deals* to look at — ranked by the shared ranker (§6), with agent-driven prioritization able to drop in later.
- Per-group config for overdue handling (§5-overdue) and agent visibility.
- The standalone `/tasks` route is removed; shared task infrastructure stays (§7).

---

## 2. Schema

New `task_groups` table and a few nullable columns on `user_tasks`. No Misc row — `taskGroupId IS NULL` *is* Misc.

(This section originally also specced a conversation hard-pin join table; removed 2026-09-20, see
the note at the top of this document.)

```ts
// apps/server/src/db/aop-schema.ts
export const taskGroups = pgTable(
  'task_groups',
  {
    id: uuid('id').primaryKey().defaultRandom(),
    userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),

    name: text('name').notNull(),                 // "Recruiting", "Priority deals", "Errands"
    color: text('color'),

    // The group's position in the user's ordered list — DISPLAY ORDER ONLY (grouped view,
    // §8). Routing no longer depends on it (the AI router sees all groups at once, §4).
    position: integer('position').notNull().default(0),

    // Natural-language description of what belongs in this group. THIS is what the AI
    // router reads to decide placement. e.g. "Anything about hiring, candidates, or
    // interviews." Write it like an instruction to a smart assistant.
    routingCriteria: text('routing_criteria'),

    // Per-group config
    overduePolicy: jsonb('overdue_policy').$type<TaskGroupOverduePolicy>(),
    agentVisible: boolean('agent_visible').notNull().default(true),

    createdAt: timestamp('created_at').notNull().defaultNow(),
    updatedAt: timestamp('updated_at').notNull().defaultNow(),
  },
  (table) => ({
    idxTaskGroupsUser: index('idx_task_groups_user').on(table.userId),
  }),
);
```

```ts
// added to user_tasks (THIS RUN)
taskGroupId: uuid('task_group_id').references(() => taskGroups.id, { onDelete: 'set null' }),
// null IS Misc (no real Misc row); onDelete:set-null so deleting a group NEVER deletes
// tasks — they fall back to the virtual Misc lane.
// + index('idx_user_tasks_group').on(table.userId, table.taskGroupId)

// DEFERRED with the Queue/ranking (not built this run): queuePinnedAt, manualQueueRank,
// manualSortOrder, agentPriorityScore/Reason/At. Added when the Queue lands.
```

```ts
type TaskGroupOverduePolicy = {
  mode: 'nag' | 'auto-complete' | 'ignore';
  afterDays: number;
  agentMayReschedule: boolean;
};
```

### Why nullable FK + `onDelete: 'set null'` (Misc is virtual)

- No Misc row. `taskGroupId IS NULL` *is* Misc — every ungrouped task belongs to it implicitly. No seeding, no uniqueness constraint, no read-side coalesce; existing rows are already null → already Misc (zero backfill).
- The list/group endpoints synthesize a **virtual Misc lane** from null-group tasks. Promote Misc to a real row only if it ever needs its own config.
- Deleting a group sets its tasks' `taskGroupId` to null → Misc, never cascade-deletes work.

---

## 3. Why there's no single-membership "tension"

Earlier drafts worried that a task two groups both want can only live in one lane, and that the *agent* choosing invites inconsistency. Both concerns dissolve once routing is a **dedicated AI router** (§4) rather than the main agent's side-judgment: the router's whole job is to pick exactly one group from the user's list. There's nothing to tie-break by hand and no fixed precedence to hardcode — the router weighs the task against every group's natural-language criteria and returns one. Single-membership is simply "the router's answer," and a task can always be moved.

---

## 4. Routing — the AI router

Every task, at creation, is filed by one service: **`routeTaskToGroup`**. Both `createTask` (tRPC) and `create-task` (Mastra tool) call it, so user-created and agent-created tasks route identically.

```
routeTaskToGroup(userId, taskDraft) -> { taskGroupId | null, routedBy, confidence }

  1. Load the user's groups (name + routingCriteria).
  2. One focused LLM classification call: given the task (description, notes, taskType,
     taskChannel) and its conversation context (deal name, status, priority, important,
     meetings held so far), pick the single best-matching group — or none.
  3. Confident match → that group (routedBy: 'ai'). No good match → null / Misc.
```

- **A small, dedicated call — not the big agent's guess.** A focused classifier prompt with a cheap model, seeing only the group list and the task. This is what makes it consistent and auditable, and it's the *same* path for every creation source.
- **Explainable.** Returns `routedBy` (`'explicit' | 'ai' | 'misc'`), the chosen group, and a confidence, all logged (§4-logging).
- **Groups are just natural language.** To add a lane the user writes a name + a sentence of criteria; the router adapts. No predicate DSL, no rule ordering to maintain.
- **Re-routing on demand.** A "re-file this group" action (or the nightly sweep for opted-in groups) can re-run the router over existing open tasks when criteria change. Default is route-once-at-creation.

### Agent task-creation tool (`create-task`)

The rule for the agent stays deliberately narrow — **it supplies facts, the router decides placement**:

- Add **one** optional input `taskGroupId`, set **only** when the user explicitly named a lane in chat (resolve the name → id server-side; on no match, fall through to the router, never fabricate a group). No free-text `groupName`/`groupHint` the agent fills from intuition.
- Flow: build the draft (as today) → `routeTaskToGroup(userId, draft, explicitGroupId?)` → persist with the returned `taskGroupId` → `createStructuredLog('task_routed', { taskId, taskGroupId, routedBy, confidence, conversationId })`.
- Existing conflict-check, scheduling, and SSE are unchanged — grouping is orthogonal.

Tool-description prose the agent reads:

> **Task groups.** Every task is filed into one of the user's groups automatically by a router. **You do not choose the group.** Do not mention or guess groups unless the user explicitly asks you to place a task in a named lane; in that case pass its `taskGroupId`.

Keeping the agent this dumb preserves consistency (same task routes the same way regardless of which agent/run created it) and the dependency direction: the tool imports the `routeTaskToGroup` **service**, never the group registry, agents, or skills. `pnpm deps:check` stays green.

`list-tasks` gains an optional `taskGroupId`/`groupName` scope and stamps each returned task with its group label + Queue rank (§5–§6), so the agent reads tasks in the user's own organization and priority order.

---

## 5. The Queue — one ordered list of what to do next

The Queue is the **prioritization axis**: a single, per-user, ranked stream answering *"what should I do or look at next?"* — spanning every group. It replaces the deleted `/tasks` browser as the primary way to work through tasks.

### What's in it — attention items

The Queue is heterogeneous. Each entry is an **attention item**, one of:

- a **task** (open `user_tasks` row), or
- a **deal to look at** — a `crmConversations` row surfaced because it needs attention even with no open task (stalled, gone quiet, important with no next step). Reuses the daily-agenda signals the tasks skill already computes.

So "tasks to do, or deals to look at" is literally one ranked feed, not two surfaces.

### How it's ordered

One global ranker (§6) scores every attention item into a single sequence:

```
Queue order (top = do first):
  1. queuePinnedAt  — user-pinned items, most-recent pin on top
  2. manualQueueRank — user hand-placed items
  3. ranked pool     — everything else by the global smart score (§6), later agent score
```

The Queue *is* the cross-group "most urgent overall" ranking that earlier drafts left as an open question. Agent-driven prioritization (§6, Phase 5b) plugs straight into step 3 — the Queue is the surface that most benefits from it.

### How the Queue and groups relate

They're orthogonal and both first-class:

- **Default view = the flat Queue** (all groups, one ranked list, each item tagged with its group color).
- **Groups are a filter/facet on the Queue** ("show only the Recruiting lane") and the organizational lens for a grouped/browse view.
- A task shows up **once** in the Queue at its rank; its group is metadata on the row, not a separate copy.
- Per-group config (overdue policy, `agentVisible`) still applies — e.g. an `agentVisible:false` group's tasks are excluded from the agent's Queue view but still visible to the user.

### Backend shape

A `listQueue` tRPC procedure + a `list-queue` agent tool returning the merged, ranked, paginated attention items (with `kind: 'task' | 'deal'`, group tag, rank, and pin/manual flags). One ranker implementation shared with the per-group list so ordering is identical everywhere.

---

## 6. Internal sorting & prioritization

One shared `rankTasks` / ranker service powers both a group's internal order **and** the global Queue. Two requirements: deals further along / more likely to close rank higher, and the ranker must be pluggable so agent-driven prioritization drops in later.

### The `smart` score (default)

Extends the existing agenda ranking (`apps/server/.claude/skills/tasks/SKILL.md`) with a stage term:

```
smartScore = priority_weight × stage_weight × log(dealValue + 1) × recency_bonus

  priority_weight: urgent=4, high=3, medium=2, low=1, unset=0      (crmConversations.priority)
  stage_weight:    later pipeline stage ⇒ higher                            ← proxy for "likely to close"
                     negotiation=4, demo/evaluation=3, qualification=2,
                     new/unset=1; closed_won/closed_lost excluded
  recency_bonus:   due today=1.2, due yesterday=1.0, older=0.8    (dueDate)
  tiebreak:        comms tasks (follow-up/response/post-meeting) > manual
```

Cedar has no explicit win-probability field; deal stage is the standard proxy. If a probability field is added, it slots into the `stage_weight` term. All inputs come from the conversation join; computed on read, no stored score.

### Sort strategies

- **`smart`** (default) — the formula above; used for the Queue and for group internal order.
- **`dueDate`** — chronological, for time-driven groups.
- **`manual`** — user drag order via `manualSortOrder` (group view) / `manualQueueRank` (Queue); nulls fall back to smart.
- **`agent`** (reserved, ships later) — ranks by `agentPriorityScore` when present, else smart.

### Keeping agent-based prioritization viable

`rankTasks` is a **pluggable scorer** from day one, even though only `smart` ships first:

```ts
// services/task-groups/rankTasks.ts
type Ranker = (items: AttentionItem[]) => Array<{ id: string; score: number }>;
const rankers: Record<SortStrategy, Ranker> = { smart, dueDate, manual, agent };
```

The `agentPriorityScore/Reason/At` columns (§2) already exist as nullable/additive, so a future agent scoring job just writes scores; users (or the Queue) flip to `agent`, or the ranker **blends** (`0.7·agent + 0.3·smart`) in one place. No schema or API change. `routeTaskToGroup` and `rankTasks` both live in `services/task-groups/`, shared by tRPC and the Mastra tools.

---

## 7. Removing the `/tasks` frontend

The standalone `/tasks` route (a date-bucketed task browser) is superseded by the Queue and is removed. **This is a surgical removal of the browsing surface, not the task module** — `userTasks` hooks/types/slice are imported by 8+ other surfaces and must stay.

### Delete

- Route declaration in [routes.ts](../../../app/routes.ts) (`layout('(routes)/tasks/layout.tsx', [route('/tasks', …)])`).
- `app/(routes)/tasks/layout.tsx`, `app/(routes)/tasks/page.tsx`.
- Route-only view pieces (confirm no external importers first): `tasks-view.tsx`, `tasks-list.tsx`, `tasks-kanban.tsx`, `use-tasks-view.ts`, `use-task-hotkeys.ts`, `sorting-popover.tsx`, `sorting-status-bar.tsx`, `attribute-ordering-popover.tsx`, `DateNavigationBar.tsx`.
- `/tasks` entries in [navigation-page-url-sync.tsx](../../../modules/ux/components/navigation-page-url-sync.tsx) and any nav/hotkey references.

### Keep (shared — do NOT delete)

- Store: `userTasksSlice` (wired into the global store) + `HydratedUserTask`, `TaskActionData`, `UserTasksSlice`, `TaskDateKey` types.
- Hooks: `use-optimistic-task-actions` (8 consumers), `use-invoke-task-in-chat` (4), `use-create-task-optimistic` (2).
- Components: `AnimatedCheckmark` (6), `StageBatchApprovalCard` (2), `TaskCommandBar` (used by conversations timeline).

These stay because conversations, crm, agentCanvas, calendar, and threads all create/act on tasks inline. Removing the route must not regress those.

---

## 8. UI — deferred (beyond the removal above)

The Queue and grouped views are specified at the data level (`listQueue`, `list-queue`, ranked attention items) and are fully drivable via tRPC + the Mastra tools without a frontend. The eventual primary surface is the Queue (likely on home/agenda, reusing `TaskCommandBar` and the surviving task hooks), with group as a filter/tag. Detailed layout is a separate pass so it doesn't block the model.

---

## 9. Critical files

| File | Change |
| --- | --- |
| `apps/server/src/db/aop-schema.ts` | `taskGroups`; `taskGroupId` + queue/agent columns on `user_tasks`; migration. |
| `apps/server/src/services/task-groups/routeTaskToGroup.ts` (new) | AI router (explicit lane → LLM classify) + `routedBy` reason. |
| `apps/server/src/services/task-groups/rankTasks.ts` (new) | Pluggable ranker; powers Queue + group order. |
| `apps/server/src/services/task-groups/buildQueue.ts` (new) | Merge tasks + deals-to-look-at → ranked attention items. |
| `apps/server/src/services/task-scheduling/` | Overdue sweep + optional re-route of `live` groups. |
| `apps/server/src/trpc/routes/task-groups.ts` (new) | Group CRUD, reorder, pin/unpin; `listQueue`. |
| `apps/server/src/trpc/routes/user-tasks.ts` | Accept/return `taskGroupId`; move-to-group; queue pin/rank. |
| `apps/server/src/mastra/tools/tasks/createTaskTool.ts` | Optional `taskGroupId`, router call, `task_routed` log. |
| `apps/server/src/mastra/tools/tasks/listTasksTool.ts` | Group scope + label + rank; add `list-queue`. |
| `apps/mail/app/routes.ts` + `app/(routes)/tasks/` | **Remove** `/tasks` route + route-only views (§7). |

---

## 10. Phased implementation (backend-first)

- [x] **Phase 1 — Schema & virtual Misc.** Added `task_groups` (+ the now-removed conversation hard-pin join table, see the note at the top of this document), `user_tasks.task_group_id` (nullable, `on delete set null`). Hand-authored `migrations/task_groups.sql` (drizzle snapshot drifted; followed the repo's hand-authored-`.sql` convention). *Verified headlessly:* migration applied to Supabase; tables + nullable uuid column present; jesse's 312 existing todo tasks all read as virtual Misc (null group) with zero backfill.
- [x] **Phase 2 — AI router.** `services/task-groups/routeTaskToGroup.ts` (explicit → pin → Haiku classify → Misc), logs via `createStructuredLog`. Wired inline into all three create paths: `create-task` tool + tRPC `createTask` + `createStandaloneTask` (optional explicit `taskGroupId` on the tRPC paths). *Verified headlessly* (`scripts/task-groups-route-smoke.ts`, real Haiku): hiring→Recruiting (conf 1.0), invoice→Billing (0.95), unrelated→Misc, hard-pin overrides AI, explicit id wins.
- [x] **Phase 3 — Groups CRUD.** `trpc/routes/task-groups.ts` (registered as `taskGroups`): `listGroups` (with synthesized virtual Misc + open-task counts), `createGroup` (position defaults to end), `updateGroup`, `reorderGroups`, `deleteGroup` (→ Misc fallback, never deletes tasks), `moveTaskToGroup` (+ the now-removed pin/unpinConversation, see the note at the top of this document). *Verified headlessly* (`scripts/task-groups-crud-smoke.ts`): position defaulting, virtual-Misc counts (jesse: 310 ungrouped), move, delete→Misc survival. **Deferred (follow-up):** `list-tasks` group scope/labels — additive; the `agentVisible` column is already in place. In-process tRPC `createCaller` is blocked under tsx by a pre-existing `email-processor → css-sanitizer` ESM issue, so the driver mirrors the procedures' exact queries; the real over-the-wire path is the HTTP `cedar-cli`.
- [ ] **Phase 4 — Ranker + Queue.** `rankTasks` (smart w/ stage weight) + `buildQueue` (tasks + deals-to-look-at); `listQueue` tRPC + `list-queue` tool; pin/manual controls. *Test:* Queue order reflects priority × stage × dealValue × recency; pins/manual float to top; deals-to-look-at appear when they have no open task.
- [ ] **Phase 5 — Overdue.** *Deferred.* The `task_groups.overdue_policy` column ships (Phase 1) with modes `nag` (default) / `auto-complete` / `ignore` — `auto-snooze` was dropped. The sweep that applies `auto-complete` (mark done after the grace window, recoverable) is not built yet. Low priority; additive when picked up.
- [x] **Phase 6 — Remove the `/tasks` route + dead code.** Removed the route decl (`app/routes.ts`), `(routes)/tasks/{layout,page}.tsx`, the `'tasks'` `NavigationPage` member + both url-sync maps, and the now-dead "Tasks" sidebar block in `HomeChatSidebar` (with its `ListTodo` import). **Kept** the shared userTasks view components (`TasksView`, `useTasksView`, `SortingPopover`, …) — they're imported by `threads/mail.tsx` and the agenda (`agentCanvas`), so only the *route* was removed, not the components. *Verified:* removing `'tasks'` from `NavigationPage` is type-clean (zero new tsc errors; the tree has 5,815 pre-existing WIP errors unrelated to this change). Committed with per-hunk isolation so the co-located `feat/chat-context-set` WIP was neither staged nor disturbed.
- [ ] **Phase 7 — Agent prioritization (later).** Populate `agentPriorityScore`; wire `sortStrategy:'agent'` / Queue blend. No schema change. *Test:* scored items rank by agent score; un-scored fall back to smart.
- [x] **Phase 8a — UI: sidebar + creation.** `TaskGroupsNav` (collapsible "Tasks" section in `HomeChatSidebar` — header → `/agenda`, sub-list of groups from `listGroups` incl. virtual Misc with color dot + open-task count, each → `/agenda?group=<id>`) and `CreateTaskGroupPopover` (inline: name + color + optional natural-language auto-file rule → `createGroup`). Typechecks clean; committed WIP-isolated.
### Phase 8b design — agenda Task-Group sections (nested)

Restructure the daily agenda so **every** task group is a top-level, collapsible **section**, and tasks live under their group. Conversation grouping is preserved **nested inside** a section (decision: nest conv groups). Collapse state is **session-only** (never written to the Y.Doc).

New doc shape (per day):

```
dateHeading
taskGroupSection { taskGroupId }        ← one per user group, in `position` order
  taskGroupHeader { taskGroupId }       ← chevron + color dot + name + open count (leaf, uneditable)
  conversationGroup { conversationId }  ← deal-linked tasks nested as today
    conversationGroupHeader
    agendaTask*
  agendaTask*                           ← standalone (non-deal) tasks in this group
taskGroupSection { taskGroupId: null }  ← Misc, always last
  …
paragraph                               ← trailing
```

**Invariant ("always show all groups"):** every group the user owns (+ virtual Misc) renders as a section every day, even with zero tasks (header only). Enforced server-side in the reconciler; empty sections are never pruned (only empty *conversation* groups are).

- **New TipTap nodes** (`agentCanvas/extensions/TaskGroupSectionNode.tsx`):
  - `taskGroupHeader` — leaf (no content, `selectable:false`), attrs `{ taskGroupId }`. NodeView: chevron (collapsed/expanded) + color dot + group name + open-task count, all resolved from a `useTaskGroupData(taskGroupId)` lookup over `listGroups` (Misc when `taskGroupId` is null). Clicking the chevron/header toggles collapse via a `TaskGroupCollapseContext`.
  - `taskGroupSection` — `group:'block'`, `defining:true`, `draggable:false`, content `taskGroupHeader (conversationGroup | agendaTask)*`, attrs `{ taskGroupId }`. NodeView wraps `NodeViewContent`; when collapsed, the content wrapper gets a `hidden` class (children stay in the doc — visual only, so nothing is written and edits are preserved).
  - `TaskGroupCollapseContext` (Set<string> of collapsed groupIds keyed by `taskGroupId ?? '__misc__'` + a toggle), provided by `AgendaDocument` from React state → session-only, no persistence.
- **AgendaTaskNode** gains a `taskGroupId` attr (default null) and its Tab/Shift-Tab/Enter logic is extended so a task inside a section keeps that section's `taskGroupId`. New task typed inside a section inherits the section's id.
- **Creation wiring:** `use-agenda-task-sync` `scheduleTaskCreation` + `invokeTask` resolve the enclosing `taskGroupId` from the node and pass it to `createStandaloneTask`; `userTasks.createStandaloneTask` accepts an optional `taskGroupId` (falls through to the AI router when absent, per §4).
- **`insert-under-new-tasks-banner` → `insert-into-task-group`:** snooze/hydration reflow inserts a task into its group section (creating the section if missing) instead of the flat "New tasks" banner.
- **Server:**
  - `DailyAgendaTaskInput` gains `taskGroupId: string | null`; `fetchActiveDailyTasks` (documents.ts) selects `userTasks.taskGroupId` and joins the group name/color.
  - `getDoc` agenda path also loads the user's groups (id, name, color, position) and passes them to the reconciler.
  - `buildDailyAgendaJson` / `reconcileDailyAgendaYDoc` rewritten to the 3-level structure: ensure a section per group (in position order, Misc last), route each task into `section → (conversationGroup | flat)`, remove inactive tasks at both nesting depths, prune empty conversation groups but **keep** empty sections.
- **`HydratedUserTask`** gains `taskGroupId: string | null` (the `listUserTasks` `findMany` already returns the column).

- [x] **Phase 8b.1 — Server: task-group-aware reconciler.** `taskGroupId` on `DailyAgendaTaskInput` + `agendaTask` build/attrs; new `DailyAgendaGroupInput`; `fetchActiveDailyTasks` selects `taskGroupId`; new `fetchActiveDailyGroups` (groups + virtual Misc) wired through `SeedFetchers`/`getDoc`; `buildDailyAgendaJson`/`reconcileDailyAgendaYDoc` rewritten to build nested `taskGroupSection → (conversationGroup | agendaTask)` with the always-show-all invariant (empty sections kept, empty conv groups pruned). Markdown mirror (`agenda-markdown.ts`) round-trips the section marker. *Verified headlessly:* `daily-agenda-reconciler.test.ts` (8 tests) — section per group, Misc last, nesting, empty-section survival, append, unsynced-node preservation; server package typecheck adds 0 errors.
- [x] **Phase 8b.2 — Client: TipTap section nodes + collapse.** `TaskGroupSectionNode.tsx` (`taskGroupHeader` leaf + `taskGroupSection`), `TaskGroupCollapseContext` (session-only), `useTaskGroupData` (reads `listGroups`), registered in `AgendaDocument` extensions; `taskGroupId` attr on `AgendaTaskNode` + Enter-split/`resolveEnclosingTaskGroupId` inheritance; collapse hides non-header children via CSS (nodes stay in the doc). Section "Add task" affordance via `onAddTaskToGroup` on `AgendaTaskContext`. Typecheck adds 0 errors. *Needs app-run verification.*
- [x] **Phase 8b.3 — Creation wiring.** `createStandaloneTask` gains optional `taskGroupId` (passed as `explicitGroupId` → wins over the router); `use-agenda-task-sync` threads the node's `taskGroupId` into it. Snooze cross-day reflow (`insertUnderNewTasksBanner`) is left as-is — it only fires in the multi-day stack (dormant in the single-day home), and the destination day's server reconcile files a rescheduled task into its correct section on next load. *Needs app-run verification.*
- [x] **Phase 8d — Date header: uneditable + undeletable.** Stripped the contenteditable + date-suggestions popover from `DateHeadingNode`; the date renders as static text (Today badge + triage button kept). The `dateHeadingProtect` plugin still blocks deletion of the leading heading. Typecheck clean. *Needs app-run verification.*
- [x] **Phase 8e — Single-day agenda + date-nav row.** `AgendaHome` now renders one `AgendaDocument` for a selected day (keyed by date) wrapped in the `MultiDayEditorRegistry` + `CrossEditorDragBus` providers, with a new `AgendaDateNav` row (contiguous window incl. today + selected, Today emphasized, selected highlighted, ‹ › step arrows, per-day open-task count from a windowed `listUserTasks` query). "Past tasks" toggle dropped. `MultiDayAgenda` retained for the `/conversations/agenda` full-page route. Typecheck clean. *Needs app-run verification.*
- [x] **Phase 8f — Rolling "today" doc + single Past lane.** The agenda doc whose date equals the caller's local today (`agendaTodayKey`, threaded through `getDoc` → `SeedContext` → the daily-agenda seed/reconcile) becomes a **rolling** doc: it aggregates the last week's tasks (`RECENT_WINDOW_DAYS = 7`) into their group sections plus a single **Past tasks** lane (reserved section key `__past__`) for anything due earlier. **No lookback cap** — the rolling fetch pulls *every* overdue task (`dueDate <= today`) so none are silently dropped; only completed tasks older than the recent week are excluded (it's a "still-open" view). `DailyAgendaTaskInput` gained `dueDate` (day-key) to drive the recent/past split. Client: `AgendaDocument` passes `agendaTodayKey`; the Past lane header renders a fixed "Past tasks", every section (incl. Past) starts **expanded** so overdue tasks are visible on load. The date nav is forward-only (today + future). Other days stay single-day. *Verified headlessly:* reconciler rolling tests; server typecheck clean. *Needs app-run verification.*
- [x] **Phase 8f.1 — Window-count badges.** Section header badges now count the open (unchecked, non-deleted) tasks **in that section's window** — computed live from the enclosing `taskGroupSection`'s nodes (`useSectionOpenCount`, recomputed on every editor transaction) — instead of the group's all-time `openTaskCount`. Shown on all sections including Past. Lookback capped at **30 days** (`ROLLING_LOOKBACK_DAYS`), so the Past lane holds days 8–30; older tasks are excluded.
- [x] **Phase 8f.2 — Fix: by-id hydration reconcile must be rolling.** Root cause of "only one task shows": `<Document>` hydrates via `getDoc.query({ documentId })` (by-id) **without** `agendaTodayKey`, so *that* reconcile ran non-rolling and `removeInactiveAgendaTasks` stripped every task not due today — and the client adopts that by-id content. Fix: `<Document>` gained a `getDocArgs` prop merged into both internal by-id `getDoc` calls (hydration + `refreshFromServer`); `AgendaDocument` passes `{ agendaTodayKey }`. Now both the by-lookup and by-id reconciles build the same rolling view. *Diagnosed against live data:* jesse's today doc held 264 correctly-nested tasks server-side (16 Misc + 248 Past) while the client showed 1 — the by-id non-rolling reconcile was the culprit.
- [x] **Phase 8g — Cmd+X soft-delete tombstone.** `Mod-x` on a task with an empty selection flips a local `deleted` attr (red ✕ + red strikethrough, "like done" but red) and fires `agenda:delete-task`, which marks the DB row deleted via the same `deleteTask` mutation — but the node is **not** removed, so the tombstone stays until the next reconcile/reload. With a non-empty selection, Cmd+X falls through to the normal cut. Typecheck/lint clean. *Needs app-run verification.*
- [ ] **Phase 8c — UI: Queue** (deferred with ranking).
- [x] **Phase 9 — "CRM updates" default lane.** Seventh default group (teal / `Database`, appended at position 6 so existing users' positions don't shift), for work that finishes *inside* the CRM rather than in someone's inbox — its criteria name `field-approval` and `crm-opportunity` explicitly. Rationale: `field-approval` was the single largest Misc population (374 of the 677-task 31-day backlog, 55%) and had nowhere to go. Icon map gained `Database` + `CalendarPlus`. *Ran against live data:* seed → 177/177 users have the lane; Misc re-route (`backfill-task-group-routing`, `forceAssign` off, minConfidence 0.5, `PER_USER_LIMIT=400`) assigned 656 of 681, dropping Misc from 677 → 27. All 374 `field-approval` + 3 `crm-opportunity` tasks landed in CRM updates; no email/follow-up task was misfiled there. Backfill's window/limit/confidence are now env-overridable — re-running does *not* reach the tail (the query always takes the newest N), so a deep backlog needs a raised limit, not repeat runs.
- [x] **Phase 9a — Router knows which meeting this is.** `countMeetingsSoFar(db, conversationId, asOf)` (exported from `routeTaskToGroup`) counts `crm_events` of type `meeting` with `occurred_at <= asOf`, and the classifier prompt now carries "meetings held with this deal so far: N". Scheduled-but-unheld meetings are excluded, so 0 = none yet, 1 = the intro happened. This makes first-meeting-vs-subsequent lanes decidable — the task title alone rarely settles it.
- [x] **Phase 9b — Opt-in post-meeting split.** `scripts/split-post-meeting-task-group.ts` (per-user, `USER_EMAIL`) splits one rep's post-meeting lane into "Post-initial meeting emails" (0–1 meetings held) and "Post-meeting follow-ups" (2+). The existing group is *renamed* rather than replaced so its id — and any conversation hard-pins on it — survives; the new lane is appended. Re-filing existing tasks is deterministic via `countMeetingsSoFar` at each task's `createdAt`, not an LLM call, since "which meeting is this" is a countable fact. Deliberately **not** a default for everyone — it only pays off for reps whose first-meeting motion differs from their ongoing one. *Ran for <email>:* 89 tasks → 64 initial / 25 subsequent.

## 11. Verification

- `pnpm deps:check` after touching tools/services (tools import the router/ranker **services**, never the reverse).
- `pnpm types` filtered to touched files.
- Migration on scratch DB; existing tasks read as Misc, no backfill.
- Axiom `task_routed` query: routing is stable.
- After Phase 6: full `apps/mail` typecheck + build; smoke the surfaces that import `userTasks`.

## 12. Decisions & open questions

1. ~~Router model & latency.~~ **Decided: inline classify on create** — the router runs synchronously in the create path before the row is persisted (task never transiently appears in Misc). Model tier: start with a fast/cheap model (Haiku) for the classifier; revisit if accuracy needs it.
2. **Deals-to-look-at scope.** Deferred with the Queue. Which signals promote a deal (stalled N days, important + no next step, …) and their cap — decide when the Queue is built.
3. ~~Queue vs. groups as default surface.~~ **Deferred: no Queue/ranking this run.** Grouped data is exposed headlessly via tRPC + agent tools; the primary-surface UI question is picked up with the Queue follow-up.