diff --git a/docs/plans/2026-04-07-tanstack-query-migration.md b/docs/plans/2026-04-07-tanstack-query-migration.md
new file mode 100644
index 00000000..06f31c36
--- /dev/null
+++ b/docs/plans/2026-04-07-tanstack-query-migration.md
@@ -0,0 +1,1772 @@
+# TanStack Query Migration & Core Extraction Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Migrate all server state from Zustand to TanStack Query, extract headless business logic to `core/` directory, preparing for monorepo extraction in Phase 6.
+
+**Architecture:** Replace Zustand's dual role (server cache + client state) with TanStack Query for server state (queries + mutations + cache) and Zustand for client-only state (UI selections, filters, drafts). WebSocket events bridge to TanStack Query via `queryClient.setQueryData` / `invalidateQueries`. A new `core/` directory under `apps/web/` acts as the future `packages/core/` incubator.
+
+**Tech Stack:** TanStack Query v5, Zustand v5, React 19, Next.js 16, TypeScript strict mode
+
+---
+
+## Current State Summary
+
+### Stores holding server data (to migrate)
+| Store | Server State Fields | API Calls in Store |
+|-------|---|---|
+| `useAuthStore` | `user` | `getMe`, `sendCode`, `verifyCode` |
+| `useWorkspaceStore` | `workspace`, `workspaces[]`, `members[]`, `agents[]`, `skills[]` | `listWorkspaces`, `listMembers`, `listAgents`, `listSkills`, `createWorkspace`, `leaveWorkspace`, `deleteWorkspace` |
+| `useIssueStore` | `issues[]` | `listIssues` |
+| `useInboxStore` | `items[]` | `listInbox` |
+| `useRuntimeStore` | `runtimes[]` | `listRuntimes` |
+
+### Custom hooks with embedded server state (to migrate)
+| Hook | State | API Calls |
+|---|---|---|
+| `useIssueTimeline` | `timeline[]` via useState | `listTimeline`, `createComment`, `updateComment`, `deleteComment`, `addReaction`, `removeReaction` |
+| `useIssueReactions` | `reactions[]` via useState | `getIssue`, `addIssueReaction`, `removeIssueReaction` |
+| `useIssueSubscribers` | `subscribers[]` via useState | `listIssueSubscribers`, `subscribeToIssue`, `unsubscribeFromIssue` |
+
+### Stores staying as-is (pure client state)
+| Store | Purpose |
+|---|---|
+| `useModalStore` | Which modal is open |
+| `useNavigationStore` | Last visited path (persisted) |
+| `useIssueSelectionStore` | Multi-select checkbox state |
+| `useIssueDraftStore` | New issue form draft (persisted) |
+| `useIssuesScopeStore` | "all" / "members" / "agents" filter (persisted) |
+| `useIssueViewStore` | Board/list mode, filters, sort (persisted) |
+| `myIssuesViewStore` | My-issues view filters (persisted) |
+
+### Files with direct `api.*` mutation calls (25+ files)
+These will be migrated to use `useMutation` hooks from `core/`.
+
+---
+
+## Target Directory Structure (after Phase 5)
+
+```
+apps/web/
+├── app/ # Routing layer (unchanged)
+├── core/ # NEW: headless business logic
+│ ├── api/ # ApiClient, WSClient (moved from shared/api/)
+│ │ ├── client.ts
+│ │ ├── ws-client.ts
+│ │ └── index.ts
+│ ├── types/ # Domain types (moved from shared/types/)
+│ │ ├── issue.ts
+│ │ ├── workspace.ts
+│ │ ├── agent.ts
+│ │ ├── events.ts
+│ │ ├── comment.ts
+│ │ ├── inbox.ts
+│ │ ├── subscriber.ts
+│ │ ├── attachment.ts
+│ │ ├── activity.ts
+│ │ ├── api.ts
+│ │ └── index.ts
+│ ├── auth/
+│ │ └── store.ts # Zustand: { user, isLoading } (client-only)
+│ ├── workspace/
+│ │ ├── queries.ts # workspaceQueries, memberQueries, agentQueries, skillQueries
+│ │ ├── mutations.ts # useCreateWorkspace, useLeaveWorkspace, useDeleteWorkspace, ...
+│ │ └── store.ts # Zustand: { currentWorkspaceId } (client-only)
+│ ├── issues/
+│ │ ├── queries.ts # issueQueries, timelineQueries, reactionQueries, subscriberQueries
+│ │ ├── mutations.ts # useCreateIssue, useUpdateIssue, useDeleteIssue, useBatchUpdate, ...
+│ │ ├── store.ts # Zustand: { activeIssueId } (client-only)
+│ │ └── config/ # status.ts, priority.ts (pure data, zero JSX)
+│ │ ├── status.ts
+│ │ ├── priority.ts
+│ │ └── index.ts
+│ ├── inbox/
+│ │ ├── queries.ts # inboxQueries (dedup as select transform)
+│ │ └── mutations.ts # useMarkRead, useArchive, useBatchMarkRead, ...
+│ ├── runtimes/
+│ │ ├── queries.ts # runtimeQueries, usageQueries, activityQueries
+│ │ └── store.ts # Zustand: { selectedRuntimeId } (client-only)
+│ ├── tasks/
+│ │ └── queries.ts # taskQueries (active task, messages, task runs)
+│ ├── settings/
+│ │ ├── queries.ts # tokenQueries
+│ │ └── mutations.ts # useUpdateMe, useCreatePAT, useRevokePAT, ...
+│ ├── realtime/
+│ │ └── sync.ts # WS event → queryClient.setQueryData / invalidateQueries
+│ ├── query-client.ts # QueryClient factory
+│ └── logger.ts # Logger utility
+├── features/ # Web-specific UI + business components
+│ ├── auth/
+│ │ ├── initializer.tsx # AuthInitializer (simplified: no workspace hydration)
+│ │ ├── auth-cookie.ts
+│ │ └── index.ts
+│ ├── workspace/
+│ │ ├── hooks.ts # useActorName (reads from TQ cache now)
+│ │ ├── components/
+│ │ └── index.ts
+│ ├── issues/
+│ │ ├── stores/ # Client-only stores (view, scope, draft, selection)
+│ │ ├── components/ # Board, list, detail, pickers, icons
+│ │ ├── utils/ # filter.ts, sort.ts
+│ │ └── index.ts
+│ ├── inbox/ # (store deleted, only re-exports from core)
+│ │ └── index.ts
+│ ├── editor/ # Tiptap editor (contains JSX, stays in features)
+│ ├── modals/ # Modal store + registry
+│ ├── realtime/
+│ │ ├── provider.tsx # WSProvider (simplified)
+│ │ ├── hooks.ts # useWSEvent, useWSReconnect
+│ │ └── index.ts
+│ ├── runtimes/
+│ │ ├── components/ # UI components
+│ │ └── index.ts
+│ ├── skills/ # Skill management UI
+│ ├── my-issues/ # My-issues page + view store
+│ ├── navigation/ # Navigation store (Next.js specific)
+│ └── landing/ # Landing page (Web only)
+├── components/ # Shared UI (future packages/ui/)
+│ ├── ui/ # ~55 shadcn components
+│ ├── common/ # actor-avatar, emoji-picker, etc.
+│ ├── markdown/
+│ └── theme-provider.tsx
+├── hooks/ # Generic UI hooks
+├── lib/ # cn() utility
+└── shared/ # DELETED by end of Phase 5
+```
+
+---
+
+## Phase 0: Infrastructure Setup
+
+### Task 0.1: Install TanStack Query
+
+**Files:**
+- Modify: `apps/web/package.json`
+
+**Step 1: Install packages**
+
+```bash
+cd apps/web && pnpm add @tanstack/react-query @tanstack/react-query-devtools
+```
+
+**Step 2: Verify installation**
+
+```bash
+pnpm typecheck
+```
+Expected: PASS (no type errors from new deps)
+
+**Step 3: Commit**
+
+```bash
+git add apps/web/package.json apps/web/pnpm-lock.yaml ../../pnpm-lock.yaml
+git commit -m "chore(web): install @tanstack/react-query and devtools"
+```
+
+---
+
+### Task 0.2: Create core/ directory and query client
+
+**Files:**
+- Create: `apps/web/core/query-client.ts`
+
+**Step 1: Create query client factory**
+
+```typescript
+import { QueryClient } from "@tanstack/react-query";
+
+export function createQueryClient() {
+ return new QueryClient({
+ defaultOptions: {
+ queries: {
+ // WS keeps data fresh — no automatic refetch on window focus
+ staleTime: Infinity,
+ // Keep unused cache for 10 minutes
+ gcTime: 10 * 60 * 1000,
+ refetchOnWindowFocus: false,
+ refetchOnReconnect: false,
+ retry: 1,
+ },
+ mutations: {
+ retry: false,
+ },
+ },
+ });
+}
+```
+
+Design decisions:
+- `staleTime: Infinity` — WebSocket events handle invalidation, no polling needed.
+- `gcTime: 10min` — Navigating away from a page and back within 10min uses cache.
+- `refetchOnWindowFocus: false` — WS connection already keeps data current.
+- Factory function (not singleton) — SSR-safe, each request gets its own client.
+
+**Step 2: Run typecheck**
+
+```bash
+pnpm typecheck
+```
+
+**Step 3: Commit**
+
+```bash
+git add apps/web/core/query-client.ts
+git commit -m "feat(core): add TanStack Query client factory"
+```
+
+---
+
+### Task 0.3: Add tsconfig path alias for @core/
+
+**Files:**
+- Modify: `apps/web/tsconfig.json`
+
+**Step 1: Add path alias**
+
+Add to `compilerOptions.paths`:
+```json
+{
+ "paths": {
+ "@/*": ["./*"],
+ "@core/*": ["./core/*"]
+ }
+}
+```
+
+**Step 2: Run typecheck**
+
+```bash
+pnpm typecheck
+```
+
+**Step 3: Commit**
+
+```bash
+git add apps/web/tsconfig.json
+git commit -m "chore(web): add @core/* tsconfig path alias"
+```
+
+---
+
+### Task 0.4: Add QueryClientProvider to root layout
+
+**Files:**
+- Create: `apps/web/core/provider.tsx`
+- Modify: `apps/web/app/layout.tsx`
+
+**Step 1: Create provider component**
+
+```typescript
+"use client";
+
+import { useState } from "react";
+import { QueryClientProvider } from "@tanstack/react-query";
+import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
+import { createQueryClient } from "@core/query-client";
+
+export function QueryProvider({ children }: { children: React.ReactNode }) {
+ const [client] = useState(createQueryClient);
+ return (
+
+ {children}
+
+
+ );
+}
+```
+
+**Step 2: Wrap root layout**
+
+In `apps/web/app/layout.tsx`, add `QueryProvider` inside `ThemeProvider`, wrapping everything:
+
+```tsx
+import { QueryProvider } from "@core/provider";
+
+// In the JSX:
+
+
+
+ {children}
+
+
+
+
+
+```
+
+**Step 3: Run typecheck and dev server**
+
+```bash
+pnpm typecheck
+pnpm dev:web # Verify app loads, check devtools panel appears
+```
+
+**Step 4: Commit**
+
+```bash
+git add apps/web/core/provider.tsx apps/web/app/layout.tsx
+git commit -m "feat(core): add QueryClientProvider to root layout"
+```
+
+---
+
+### Task 0.5: Create useWorkspaceId utility hook
+
+**Files:**
+- Create: `apps/web/core/hooks.ts`
+
+**Step 1: Create hook**
+
+This hook reads the current workspace ID from the workspace store. All query keys will use this to scope data per workspace.
+
+```typescript
+import { useWorkspaceStore } from "@/features/workspace";
+
+/**
+ * Returns the current workspace ID.
+ * All workspace-scoped queries should use this in their query key.
+ */
+export function useWorkspaceId(): string | null {
+ return useWorkspaceStore((s) => s.workspace?.id ?? null);
+}
+```
+
+Note: During Phase 3 (workspace migration), this will read from `core/workspace/store.ts` instead. For now it bridges to the existing store.
+
+**Step 2: Run typecheck**
+
+```bash
+pnpm typecheck
+```
+
+**Step 3: Commit**
+
+```bash
+git add apps/web/core/hooks.ts
+git commit -m "feat(core): add useWorkspaceId utility hook"
+```
+
+---
+
+## Phase 1: Issues Migration
+
+> The issues domain is the largest and most complex. It validates all patterns (queries, mutations, optimistic updates, WS sync, cache management) that the other domains will follow.
+
+### Task 1.1: Create issue query key factory and queryOptions
+
+**Files:**
+- Create: `apps/web/core/issues/queries.ts`
+
+**Step 1: Write query definitions**
+
+```typescript
+import { queryOptions } from "@tanstack/react-query";
+import { api } from "@/shared/api";
+
+export const issueKeys = {
+ all: ["issues"] as const,
+ lists: () => [...issueKeys.all, "list"] as const,
+ list: (workspaceId: string | null) =>
+ [...issueKeys.lists(), workspaceId] as const,
+ details: () => [...issueKeys.all, "detail"] as const,
+ detail: (id: string) => [...issueKeys.details(), id] as const,
+ timeline: (issueId: string) =>
+ [...issueKeys.all, "timeline", issueId] as const,
+ reactions: (issueId: string) =>
+ [...issueKeys.all, "reactions", issueId] as const,
+ subscribers: (issueId: string) =>
+ [...issueKeys.all, "subscribers", issueId] as const,
+};
+
+export function issueListOptions(workspaceId: string | null) {
+ return queryOptions({
+ queryKey: issueKeys.list(workspaceId),
+ queryFn: () => api.listIssues({ limit: 200 }),
+ select: (data) => data.issues,
+ enabled: !!workspaceId,
+ });
+}
+
+export function issueDetailOptions(id: string) {
+ return queryOptions({
+ queryKey: issueKeys.detail(id),
+ queryFn: () => api.getIssue(id),
+ });
+}
+
+export function issueTimelineOptions(issueId: string) {
+ return queryOptions({
+ queryKey: issueKeys.timeline(issueId),
+ queryFn: () => api.listTimeline(issueId),
+ });
+}
+
+export function issueReactionsOptions(issueId: string) {
+ return queryOptions({
+ queryKey: issueKeys.reactions(issueId),
+ queryFn: async () => {
+ const issue = await api.getIssue(issueId);
+ return issue.reactions ?? [];
+ },
+ });
+}
+
+export function issueSubscribersOptions(issueId: string) {
+ return queryOptions({
+ queryKey: issueKeys.subscribers(issueId),
+ queryFn: () => api.listIssueSubscribers(issueId),
+ });
+}
+```
+
+Key design patterns:
+- **Query key factory** — Hierarchical keys enable targeted invalidation. `invalidateQueries({ queryKey: issueKeys.all })` invalidates everything; `issueKeys.list(wsId)` only invalidates the list for that workspace.
+- **`queryOptions()`** — TanStack Query v5 helper that bundles queryKey + queryFn. Ensures type safety — `useQuery(issueListOptions(wsId))` infers the return type.
+- **`enabled: !!workspaceId`** — Don't fetch if no workspace selected (avoids 400s during init).
+- **`select`** — Transform response inline. `issueListOptions` unwraps `{ issues: Issue[] }` → `Issue[]`.
+
+**Step 2: Run typecheck**
+
+```bash
+pnpm typecheck
+```
+
+**Step 3: Commit**
+
+```bash
+git add apps/web/core/issues/queries.ts
+git commit -m "feat(core/issues): add query key factory and queryOptions"
+```
+
+---
+
+### Task 1.2: Create issue mutations
+
+**Files:**
+- Create: `apps/web/core/issues/mutations.ts`
+
+**Step 1: Write mutation hooks**
+
+```typescript
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { api } from "@/shared/api";
+import { issueKeys } from "./queries";
+import type { Issue } from "@/shared/types";
+import type { CreateIssueRequest, UpdateIssueRequest } from "@/shared/types";
+
+export function useCreateIssue() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: (data: CreateIssueRequest) => api.createIssue(data),
+ onSuccess: (newIssue) => {
+ // Add to list cache directly (WS event also does this, but local is faster)
+ qc.setQueryData(
+ issueKeys.list(newIssue.workspace_id),
+ (old) => (old ? [...old, newIssue] : [newIssue]),
+ );
+ },
+ });
+}
+
+export function useUpdateIssue() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: ({ id, ...data }: { id: string } & UpdateIssueRequest) =>
+ api.updateIssue(id, data),
+ onMutate: async ({ id, ...data }) => {
+ // Cancel in-flight fetches for this list
+ await qc.cancelQueries({ queryKey: issueKeys.lists() });
+
+ // Snapshot for rollback
+ const previousList = qc.getQueriesData({
+ queryKey: issueKeys.lists(),
+ });
+
+ // Optimistic update: patch issue in all list caches
+ qc.setQueriesData({ queryKey: issueKeys.lists() }, (old) =>
+ old?.map((i) => (i.id === id ? { ...i, ...data } : i)),
+ );
+
+ // Also update detail cache if it exists
+ qc.setQueryData(issueKeys.detail(id), (old) =>
+ old ? { ...old, ...data } : old,
+ );
+
+ return { previousList };
+ },
+ onError: (_err, _vars, context) => {
+ // Rollback
+ if (context?.previousList) {
+ for (const [key, data] of context.previousList) {
+ if (data) qc.setQueryData(key, data);
+ }
+ }
+ },
+ onSettled: (_data, _err, { id }) => {
+ // Refetch to ensure consistency
+ qc.invalidateQueries({ queryKey: issueKeys.detail(id) });
+ },
+ });
+}
+
+export function useDeleteIssue() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: (id: string) => api.deleteIssue(id),
+ onMutate: async (id) => {
+ await qc.cancelQueries({ queryKey: issueKeys.lists() });
+ const previousList = qc.getQueriesData({
+ queryKey: issueKeys.lists(),
+ });
+ qc.setQueriesData({ queryKey: issueKeys.lists() }, (old) =>
+ old?.filter((i) => i.id !== id),
+ );
+ qc.removeQueries({ queryKey: issueKeys.detail(id) });
+ return { previousList };
+ },
+ onError: (_err, _id, context) => {
+ if (context?.previousList) {
+ for (const [key, data] of context.previousList) {
+ if (data) qc.setQueryData(key, data);
+ }
+ }
+ },
+ });
+}
+
+export function useBatchUpdateIssues() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: ({
+ ids,
+ updates,
+ }: {
+ ids: string[];
+ updates: UpdateIssueRequest;
+ }) => api.batchUpdateIssues(ids, updates),
+ onMutate: async ({ ids, updates }) => {
+ await qc.cancelQueries({ queryKey: issueKeys.lists() });
+ const previousList = qc.getQueriesData({
+ queryKey: issueKeys.lists(),
+ });
+ qc.setQueriesData({ queryKey: issueKeys.lists() }, (old) =>
+ old?.map((i) => (ids.includes(i.id) ? { ...i, ...updates } : i)),
+ );
+ return { previousList };
+ },
+ onError: (_err, _vars, context) => {
+ if (context?.previousList) {
+ for (const [key, data] of context.previousList) {
+ if (data) qc.setQueryData(key, data);
+ }
+ }
+ },
+ onSettled: () => {
+ qc.invalidateQueries({ queryKey: issueKeys.lists() });
+ },
+ });
+}
+
+export function useBatchDeleteIssues() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: (ids: string[]) => api.batchDeleteIssues(ids),
+ onMutate: async (ids) => {
+ await qc.cancelQueries({ queryKey: issueKeys.lists() });
+ const previousList = qc.getQueriesData({
+ queryKey: issueKeys.lists(),
+ });
+ qc.setQueriesData({ queryKey: issueKeys.lists() }, (old) =>
+ old?.filter((i) => !ids.includes(i.id)),
+ );
+ return { previousList };
+ },
+ onError: (_err, _ids, context) => {
+ if (context?.previousList) {
+ for (const [key, data] of context.previousList) {
+ if (data) qc.setQueryData(key, data);
+ }
+ }
+ },
+ });
+}
+```
+
+Patterns:
+- **Optimistic update with rollback** — `onMutate` saves snapshot, patches cache; `onError` restores snapshot.
+- **`setQueriesData` (plural)** — Updates all matching caches (e.g. if two components have different list queries).
+- **`onSettled` invalidation** — After mutation completes (success or failure), refetch to sync truth.
+
+**Step 2: Run typecheck**
+
+```bash
+pnpm typecheck
+```
+
+Note: `CreateIssueRequest` and `UpdateIssueRequest` types may need to be defined or imported. Check `shared/types/api.ts` for existing request types. If they don't exist, define them in the same file or in `shared/types/api.ts`.
+
+**Step 3: Commit**
+
+```bash
+git add apps/web/core/issues/mutations.ts
+git commit -m "feat(core/issues): add mutation hooks with optimistic updates"
+```
+
+---
+
+### Task 1.3: Create issue client-only store
+
+**Files:**
+- Create: `apps/web/core/issues/store.ts`
+
+**Step 1: Write minimal client store**
+
+```typescript
+import { create } from "zustand";
+
+interface IssueClientState {
+ activeIssueId: string | null;
+ setActiveIssue: (id: string | null) => void;
+}
+
+export const useIssueClientStore = create((set) => ({
+ activeIssueId: null,
+ setActiveIssue: (id) => set({ activeIssueId: id }),
+}));
+```
+
+This is everything that remains of `useIssueStore` after server state moves to TanStack Query.
+
+**Step 2: Commit**
+
+```bash
+git add apps/web/core/issues/store.ts
+git commit -m "feat(core/issues): add client-only store for activeIssueId"
+```
+
+---
+
+### Task 1.4: Create core/issues/index.ts barrel export
+
+**Files:**
+- Create: `apps/web/core/issues/index.ts`
+
+**Step 1: Create barrel**
+
+```typescript
+export {
+ issueKeys,
+ issueListOptions,
+ issueDetailOptions,
+ issueTimelineOptions,
+ issueReactionsOptions,
+ issueSubscribersOptions,
+} from "./queries";
+
+export {
+ useCreateIssue,
+ useUpdateIssue,
+ useDeleteIssue,
+ useBatchUpdateIssues,
+ useBatchDeleteIssues,
+} from "./mutations";
+
+export { useIssueClientStore } from "./store";
+```
+
+**Step 2: Commit**
+
+```bash
+git add apps/web/core/issues/index.ts
+git commit -m "feat(core/issues): add barrel export"
+```
+
+---
+
+### Task 1.5: Migrate issue timeline hook
+
+**Files:**
+- Modify: `apps/web/features/issues/hooks/use-issue-timeline.ts`
+
+**Context:** This hook currently manages its own `useState` + manual `useEffect` fetching + manual WS subscription. Replace with `useQuery` + `useMutation` from core.
+
+**Step 1: Rewrite hook**
+
+Replace the entire hook to use TanStack Query:
+- `useQuery(issueTimelineOptions(issueId))` for initial data + cache
+- `useMutation` for `createComment`, `editComment`, `deleteComment`, `toggleReaction`
+- `useWSEvent` handlers call `queryClient.setQueryData` to append/update entries
+- `useWSReconnect` calls `queryClient.invalidateQueries` (replaces manual refetch)
+- Optimistic updates for comment CRUD and reactions
+
+Key changes:
+- Remove all `useState` for timeline data
+- Remove all `useEffect` for data fetching
+- Remove manual WS → setState syncing
+- Keep the public API shape the same so consumers don't change
+
+**Step 2: Update consumers**
+
+Check all imports of `useIssueTimeline` — the return type should be compatible. If it previously returned `{ timeline, loading, submitComment, ... }`, the new version should return the same shape.
+
+**Step 3: Run typecheck + test**
+
+```bash
+pnpm typecheck
+pnpm test
+```
+
+**Step 4: Commit**
+
+```bash
+git add apps/web/features/issues/hooks/use-issue-timeline.ts
+git commit -m "refactor(issues): migrate useIssueTimeline to TanStack Query"
+```
+
+---
+
+### Task 1.6: Migrate issue reactions hook
+
+**Files:**
+- Modify: `apps/web/features/issues/hooks/use-issue-reactions.ts`
+
+**Step 1: Rewrite hook**
+
+Replace `useState` + `useEffect` + manual fetch with:
+- `useQuery(issueReactionsOptions(issueId))`
+- `useMutation` for `toggleReaction` (optimistic add/remove in cache)
+- WS events `issue_reaction:added` / `issue_reaction:removed` → `queryClient.setQueryData`
+
+**Step 2: Run typecheck**
+
+```bash
+pnpm typecheck
+```
+
+**Step 3: Commit**
+
+```bash
+git add apps/web/features/issues/hooks/use-issue-reactions.ts
+git commit -m "refactor(issues): migrate useIssueReactions to TanStack Query"
+```
+
+---
+
+### Task 1.7: Migrate issue subscribers hook
+
+**Files:**
+- Modify: `apps/web/features/issues/hooks/use-issue-subscribers.ts`
+
+**Step 1: Rewrite hook**
+
+Replace with:
+- `useQuery(issueSubscribersOptions(issueId))`
+- `useMutation` for `toggleSubscriber` (optimistic add/remove)
+- WS events `subscriber:added` / `subscriber:removed` → `queryClient.setQueryData`
+
+**Step 2: Run typecheck**
+
+```bash
+pnpm typecheck
+```
+
+**Step 3: Commit**
+
+```bash
+git add apps/web/features/issues/hooks/use-issue-subscribers.ts
+git commit -m "refactor(issues): migrate useIssueSubscribers to TanStack Query"
+```
+
+---
+
+### Task 1.8: Migrate issue list consumers
+
+**Files to update (all read from `useIssueStore`):**
+- `apps/web/features/issues/components/issues-page.tsx`
+- `apps/web/features/issues/components/board-view.tsx`
+- `apps/web/features/issues/components/list-view.tsx`
+- `apps/web/features/issues/components/board-card.tsx`
+- `apps/web/features/issues/components/batch-action-toolbar.tsx`
+- `apps/web/features/my-issues/components/my-issues-page.tsx`
+
+**Step 1: Replace store reads with useQuery**
+
+In each file:
+```typescript
+// Before
+const issues = useIssueStore((s) => s.issues);
+const loading = useIssueStore((s) => s.loading);
+
+// After
+import { useQuery } from "@tanstack/react-query";
+import { issueListOptions } from "@core/issues";
+import { useWorkspaceId } from "@core/hooks";
+
+const workspaceId = useWorkspaceId();
+const { data: issues = [], isLoading: loading } = useQuery(issueListOptions(workspaceId));
+```
+
+**Step 2: Replace store mutations with mutation hooks**
+
+In each file that calls `api.updateIssue`, `api.createIssue`, etc.:
+```typescript
+// Before
+await api.updateIssue(id, updates);
+useIssueStore.getState().updateIssue(id, updates);
+
+// After
+const updateIssue = useUpdateIssue();
+await updateIssue.mutateAsync({ id, ...updates });
+// Cache is updated automatically via onMutate optimistic update
+```
+
+**Step 3: Replace activeIssueId**
+
+```typescript
+// Before
+const activeIssueId = useIssueStore((s) => s.activeIssueId);
+useIssueStore.getState().setActiveIssue(id);
+
+// After
+import { useIssueClientStore } from "@core/issues";
+const activeIssueId = useIssueClientStore((s) => s.activeIssueId);
+useIssueClientStore.getState().setActiveIssue(id);
+```
+
+**Step 4: Run typecheck + test**
+
+```bash
+pnpm typecheck
+pnpm test
+```
+
+**Step 5: Commit per file or group**
+
+```bash
+git commit -m "refactor(issues): migrate issue list consumers to TanStack Query"
+```
+
+---
+
+### Task 1.9: Migrate issue detail component
+
+**Files:**
+- Modify: `apps/web/features/issues/components/issue-detail.tsx`
+
+**Step 1: Replace local fetch with useQuery**
+
+```typescript
+// Before: useState + useEffect + api.getIssue(id)
+// After:
+const { data: issue, isLoading } = useQuery(issueDetailOptions(issueId));
+```
+
+**Step 2: Replace mutation calls**
+
+```typescript
+// Before: api.updateIssue(id, updates) + manual state rollback
+// After:
+const updateIssue = useUpdateIssue();
+const deleteIssue = useDeleteIssue();
+// Optimistic update + rollback is handled by the mutation hook
+```
+
+**Step 3: Run typecheck**
+
+```bash
+pnpm typecheck
+```
+
+**Step 4: Commit**
+
+```bash
+git commit -m "refactor(issues): migrate issue-detail to TanStack Query"
+```
+
+---
+
+### Task 1.10: Migrate create issue modal
+
+**Files:**
+- Modify: `apps/web/features/modals/create-issue.tsx`
+
+**Step 1: Replace direct api call with mutation**
+
+```typescript
+// Before: const issue = await api.createIssue(data);
+// After:
+const createIssue = useCreateIssue();
+const issue = await createIssue.mutateAsync(data);
+```
+
+**Step 2: Run typecheck**
+
+```bash
+pnpm typecheck
+```
+
+**Step 3: Commit**
+
+```bash
+git commit -m "refactor(modals): migrate create-issue to useCreateIssue mutation"
+```
+
+---
+
+### Task 1.11: Update realtime sync for issues
+
+**Files:**
+- Modify: `apps/web/features/realtime/use-realtime-sync.ts`
+
+**Step 1: Replace Zustand store writes with queryClient operations**
+
+```typescript
+// Before:
+useIssueStore.getState().updateIssue(issue.id, issue);
+useIssueStore.getState().addIssue(issue);
+useIssueStore.getState().removeIssue(issue_id);
+
+// After:
+import { useQueryClient } from "@tanstack/react-query";
+import { issueKeys } from "@core/issues";
+
+// In the hook:
+const queryClient = useQueryClient();
+
+// issue:updated → patch cache directly
+queryClient.setQueryData(
+ issueKeys.list(workspaceId),
+ (old) => old?.map((i) => (i.id === issue.id ? { ...i, ...issue } : i)),
+);
+// Also update detail cache
+queryClient.setQueryData(
+ issueKeys.detail(issue.id),
+ (old) => old ? { ...old, ...issue } : old,
+);
+
+// issue:created → append to list cache
+queryClient.setQueryData(
+ issueKeys.list(workspaceId),
+ (old) => old && !old.some((i) => i.id === issue.id) ? [...old, issue] : old,
+);
+
+// issue:deleted → remove from list cache
+queryClient.setQueryData(
+ issueKeys.list(workspaceId),
+ (old) => old?.filter((i) => i.id !== issue_id),
+);
+queryClient.removeQueries({ queryKey: issueKeys.detail(issue_id) });
+```
+
+**Step 2: Replace reconnect handler**
+
+```typescript
+// Before:
+useIssueStore.getState().fetch();
+
+// After:
+queryClient.invalidateQueries({ queryKey: issueKeys.all });
+```
+
+Note: Keep the inbox/workspace/agent/member/skill handlers as-is for now. They'll be migrated in Phase 2-4.
+
+**Step 3: Run typecheck**
+
+```bash
+pnpm typecheck
+```
+
+**Step 4: Commit**
+
+```bash
+git commit -m "refactor(realtime): migrate issue WS events to queryClient"
+```
+
+---
+
+### Task 1.12: Remove server state from useIssueStore
+
+**Files:**
+- Modify: `apps/web/features/issues/store.ts`
+
+**Step 1: Strip server state and methods**
+
+Remove: `issues`, `loading`, `fetch`, `setIssues`, `addIssue`, `updateIssue`, `removeIssue`
+Keep: `activeIssueId`, `setActiveIssue`
+
+Or better: delete the file entirely if no one imports `activeIssueId` from it anymore (they should import from `@core/issues`).
+
+**Step 2: Update all remaining imports**
+
+Search for `from "@/features/issues"` that still reference `useIssueStore` for server data. Replace with `@core/issues`.
+
+```bash
+# Find all remaining useIssueStore imports
+grep -rn "useIssueStore" apps/web/
+```
+
+**Step 3: Remove the import from workspace store**
+
+In `apps/web/features/workspace/store.ts`, remove:
+```typescript
+import { useIssueStore } from "@/features/issues";
+// And all calls to useIssueStore.getState().fetch() / setIssues()
+```
+
+These are no longer needed — TanStack Query automatically fetches when the workspace ID changes in the query key.
+
+**Step 4: Run full check**
+
+```bash
+make check
+```
+
+**Step 5: Commit**
+
+```bash
+git commit -m "refactor(issues): remove server state from useIssueStore"
+```
+
+---
+
+### Task 1.13: Move issues config to core
+
+**Files:**
+- Move: `apps/web/features/issues/config/status.ts` → `apps/web/core/issues/config/status.ts`
+- Move: `apps/web/features/issues/config/priority.ts` → `apps/web/core/issues/config/priority.ts`
+- Create: `apps/web/core/issues/config/index.ts`
+
+**Step 1: Move files**
+
+Move the config files. These are pure data (no JSX, no react-dom), so they belong in core.
+
+**Important:** Only move the data/constants. If these files export React components (like `StatusIcon`), keep the components in `features/issues/components/` and only move the data objects.
+
+**Step 2: Update imports**
+
+```bash
+grep -rn "from.*issues/config" apps/web/
+```
+
+Replace `@/features/issues/config` with `@core/issues/config` in all consumers.
+
+**Step 3: Run typecheck**
+
+```bash
+pnpm typecheck
+```
+
+**Step 4: Commit**
+
+```bash
+git commit -m "refactor(core/issues): move status/priority config to core"
+```
+
+---
+
+## Phase 2: Inbox Migration
+
+### Task 2.1: Create inbox query key factory and queryOptions
+
+**Files:**
+- Create: `apps/web/core/inbox/queries.ts`
+
+**Step 1: Write query definitions**
+
+```typescript
+import { queryOptions } from "@tanstack/react-query";
+import { api } from "@/shared/api";
+import type { InboxItem } from "@/shared/types";
+
+export const inboxKeys = {
+ all: ["inbox"] as const,
+ list: (workspaceId: string | null) =>
+ [...inboxKeys.all, "list", workspaceId] as const,
+ unreadCount: (workspaceId: string | null) =>
+ [...inboxKeys.all, "unread-count", workspaceId] as const,
+};
+
+/**
+ * Deduplicates inbox items by issue_id, keeping the latest entry.
+ * This was previously `useInboxStore.dedupedItems()`.
+ */
+function deduplicateInboxItems(items: InboxItem[]): InboxItem[] {
+ const map = new Map();
+ // Sort by created_at DESC first, then dedup keeps first occurrence
+ const sorted = [...items].sort(
+ (a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
+ );
+ for (const item of sorted) {
+ if (!item.archived && !map.has(item.issue_id)) {
+ map.set(item.issue_id, item);
+ }
+ }
+ return Array.from(map.values());
+}
+
+export function inboxListOptions(workspaceId: string | null) {
+ return queryOptions({
+ queryKey: inboxKeys.list(workspaceId),
+ queryFn: () => api.listInbox(),
+ select: deduplicateInboxItems,
+ enabled: !!workspaceId,
+ });
+}
+
+/**
+ * Raw inbox items (not deduplicated). Used when you need the full list
+ * for batch operations or status updates.
+ */
+export function inboxRawListOptions(workspaceId: string | null) {
+ return queryOptions({
+ queryKey: inboxKeys.list(workspaceId),
+ queryFn: () => api.listInbox(),
+ enabled: !!workspaceId,
+ });
+}
+```
+
+Key: `deduplicateInboxItems` is a `select` transform, so dedup happens on read, not on storage. The raw cache always holds the full list.
+
+**Step 2: Run typecheck**
+
+```bash
+pnpm typecheck
+```
+
+**Step 3: Commit**
+
+```bash
+git add apps/web/core/inbox/queries.ts
+git commit -m "feat(core/inbox): add query key factory with dedup select transform"
+```
+
+---
+
+### Task 2.2: Create inbox mutations
+
+**Files:**
+- Create: `apps/web/core/inbox/mutations.ts`
+
+**Step 1: Write mutation hooks**
+
+```typescript
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { api } from "@/shared/api";
+import { inboxKeys } from "./queries";
+import type { InboxItem } from "@/shared/types";
+
+export function useMarkInboxRead() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: (id: string) => api.markInboxRead(id),
+ onMutate: async (id) => {
+ await qc.cancelQueries({ queryKey: inboxKeys.all });
+ const prev = qc.getQueriesData({ queryKey: inboxKeys.all });
+ qc.setQueriesData({ queryKey: inboxKeys.all }, (old) =>
+ old?.map((item) => (item.id === id ? { ...item, read: true } : item)),
+ );
+ return { prev };
+ },
+ onError: (_err, _id, ctx) => {
+ ctx?.prev?.forEach(([key, data]) => { if (data) qc.setQueryData(key, data); });
+ },
+ });
+}
+
+export function useArchiveInbox() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: (id: string) => api.archiveInbox(id),
+ onMutate: async (id) => {
+ await qc.cancelQueries({ queryKey: inboxKeys.all });
+ const prev = qc.getQueriesData({ queryKey: inboxKeys.all });
+ qc.setQueriesData({ queryKey: inboxKeys.all }, (old) =>
+ old?.map((item) => (item.id === id ? { ...item, archived: true } : item)),
+ );
+ return { prev };
+ },
+ onError: (_err, _id, ctx) => {
+ ctx?.prev?.forEach(([key, data]) => { if (data) qc.setQueryData(key, data); });
+ },
+ });
+}
+
+export function useMarkAllInboxRead() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: () => api.markAllInboxRead(),
+ onMutate: async () => {
+ await qc.cancelQueries({ queryKey: inboxKeys.all });
+ const prev = qc.getQueriesData({ queryKey: inboxKeys.all });
+ qc.setQueriesData({ queryKey: inboxKeys.all }, (old) =>
+ old?.map((item) => (item.archived ? item : { ...item, read: true })),
+ );
+ return { prev };
+ },
+ onError: (_err, _vars, ctx) => {
+ ctx?.prev?.forEach(([key, data]) => { if (data) qc.setQueryData(key, data); });
+ },
+ });
+}
+
+export function useArchiveAllInbox() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: () => api.archiveAllInbox(),
+ onSettled: () => {
+ qc.invalidateQueries({ queryKey: inboxKeys.all });
+ },
+ });
+}
+
+export function useArchiveAllReadInbox() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: () => api.archiveAllReadInbox(),
+ onSettled: () => {
+ qc.invalidateQueries({ queryKey: inboxKeys.all });
+ },
+ });
+}
+```
+
+**Step 2: Run typecheck**
+
+```bash
+pnpm typecheck
+```
+
+**Step 3: Commit**
+
+```bash
+git add apps/web/core/inbox/mutations.ts
+git commit -m "feat(core/inbox): add inbox mutation hooks"
+```
+
+---
+
+### Task 2.3: Create inbox barrel + migrate consumers + update WS sync + delete store
+
+**Files:**
+- Create: `apps/web/core/inbox/index.ts`
+- Modify: `apps/web/app/(dashboard)/inbox/page.tsx` — replace `useInboxStore` reads + `api.*` calls
+- Modify: `apps/web/features/realtime/use-realtime-sync.ts` — replace inbox store writes
+- Modify: `apps/web/features/workspace/store.ts` — remove `useInboxStore.getState().fetch()`
+- Modify: `apps/web/features/inbox/store.ts` — delete or gut
+
+**Step 1: Create barrel**
+
+```typescript
+export { inboxKeys, inboxListOptions, inboxRawListOptions } from "./queries";
+export {
+ useMarkInboxRead,
+ useArchiveInbox,
+ useMarkAllInboxRead,
+ useArchiveAllInbox,
+ useArchiveAllReadInbox,
+} from "./mutations";
+```
+
+**Step 2: Migrate inbox page**
+
+Replace all `useInboxStore` reads with `useQuery(inboxListOptions(workspaceId))`.
+Replace all `api.markInboxRead()`, `api.archiveInbox()` etc. with mutation hooks.
+
+**Step 3: Update WS sync**
+
+```typescript
+// Before:
+useInboxStore.getState().addItem(item);
+
+// After:
+queryClient.setQueryData(
+ inboxKeys.list(workspaceId),
+ (old) => old && !old.some((i) => i.id === item.id) ? [item, ...old] : old,
+);
+```
+
+**Step 4: Remove inbox fetch from workspace store**
+
+In `workspace/store.ts` remove: `useInboxStore.getState().fetch().catch(() => {})` from `hydrateWorkspace`.
+
+**Step 5: Delete inbox store server state**
+
+Delete `apps/web/features/inbox/store.ts` if nothing else uses it.
+
+**Step 6: Run full check**
+
+```bash
+make check
+```
+
+**Step 7: Commit**
+
+```bash
+git commit -m "refactor(inbox): migrate to TanStack Query, delete useInboxStore"
+```
+
+---
+
+## Phase 3: Workspace Migration
+
+### Task 3.1: Create workspace query key factory and queryOptions
+
+**Files:**
+- Create: `apps/web/core/workspace/queries.ts`
+
+```typescript
+import { queryOptions } from "@tanstack/react-query";
+import { api } from "@/shared/api";
+
+export const workspaceKeys = {
+ all: ["workspaces"] as const,
+ list: () => [...workspaceKeys.all, "list"] as const,
+ detail: (id: string) => [...workspaceKeys.all, "detail", id] as const,
+ members: (workspaceId: string) =>
+ [...workspaceKeys.all, "members", workspaceId] as const,
+ agents: (workspaceId: string) =>
+ [...workspaceKeys.all, "agents", workspaceId] as const,
+ skills: (workspaceId: string | null) =>
+ [...workspaceKeys.all, "skills", workspaceId] as const,
+};
+
+export function workspaceListOptions() {
+ return queryOptions({
+ queryKey: workspaceKeys.list(),
+ queryFn: () => api.listWorkspaces(),
+ });
+}
+
+export function memberListOptions(workspaceId: string | null) {
+ return queryOptions({
+ queryKey: workspaceKeys.members(workspaceId!),
+ queryFn: () => api.listMembers(workspaceId!),
+ enabled: !!workspaceId,
+ });
+}
+
+export function agentListOptions(workspaceId: string | null) {
+ return queryOptions({
+ queryKey: workspaceKeys.agents(workspaceId!),
+ queryFn: () =>
+ api.listAgents({ workspace_id: workspaceId!, include_archived: true }),
+ enabled: !!workspaceId,
+ });
+}
+
+export function skillListOptions(workspaceId: string | null) {
+ return queryOptions({
+ queryKey: workspaceKeys.skills(workspaceId),
+ queryFn: () => api.listSkills(),
+ enabled: !!workspaceId,
+ });
+}
+```
+
+---
+
+### Task 3.2: Create workspace mutations
+
+**Files:**
+- Create: `apps/web/core/workspace/mutations.ts`
+
+Includes: `useCreateWorkspace`, `useUpdateWorkspace`, `useLeaveWorkspace`, `useDeleteWorkspace`, member CRUD mutations, agent CRUD mutations, skill CRUD mutations.
+
+---
+
+### Task 3.3: Create workspace client store
+
+**Files:**
+- Create: `apps/web/core/workspace/store.ts`
+
+```typescript
+import { create } from "zustand";
+import { api } from "@/shared/api";
+
+interface WorkspaceClientState {
+ currentWorkspaceId: string | null;
+ setCurrentWorkspaceId: (id: string | null) => void;
+}
+
+export const useWorkspaceClientStore = create((set) => ({
+ currentWorkspaceId: localStorage.getItem("multica_workspace_id"),
+ setCurrentWorkspaceId: (id) => {
+ if (id) {
+ localStorage.setItem("multica_workspace_id", id);
+ api.setWorkspaceId(id);
+ } else {
+ localStorage.removeItem("multica_workspace_id");
+ api.setWorkspaceId(null);
+ }
+ set({ currentWorkspaceId: id });
+ },
+}));
+```
+
+This replaces the workspace selection logic from the old `useWorkspaceStore`. The actual workspace data (name, settings, etc.) comes from `useQuery(workspaceListOptions())`.
+
+---
+
+### Task 3.4: Migrate workspace consumers + simplify AuthInitializer
+
+**Key change:** Delete `hydrateWorkspace()`. The old orchestration:
+
+```
+hydrateWorkspace() → api.listMembers + api.listAgents + api.listSkills + issueStore.fetch + inboxStore.fetch
+```
+
+Becomes: Nothing. Each component calls `useQuery(memberListOptions(workspaceId))` etc. When `workspaceId` changes, all queries with that key automatically refetch.
+
+**AuthInitializer simplification:**
+```typescript
+// Before: api.getMe() → api.listWorkspaces() → hydrateWorkspace(wsList, preferredId)
+// After: api.getMe() → set user → set currentWorkspaceId (TQ handles the rest)
+```
+
+---
+
+### Task 3.5: Update workspace switch logic
+
+**Before:** Manual cross-store clearing + rehydration
+**After:** Just change `currentWorkspaceId` in the client store. TanStack Query sees new key → refetch. Optionally `removeQueries` for the old workspace to free memory.
+
+```typescript
+function switchWorkspace(newId: string) {
+ const oldId = useWorkspaceClientStore.getState().currentWorkspaceId;
+ useWorkspaceClientStore.getState().setCurrentWorkspaceId(newId);
+
+ // Remove old workspace cache to free memory
+ if (oldId) {
+ queryClient.removeQueries({ queryKey: issueKeys.list(oldId) });
+ queryClient.removeQueries({ queryKey: inboxKeys.list(oldId) });
+ queryClient.removeQueries({ queryKey: workspaceKeys.members(oldId) });
+ queryClient.removeQueries({ queryKey: workspaceKeys.agents(oldId) });
+ }
+}
+```
+
+---
+
+### Task 3.6: Update WS sync for workspace/member/agent/skill events
+
+Replace all `useWorkspaceStore.getState().refreshMembers()` etc. with `queryClient.invalidateQueries()`.
+
+---
+
+### Task 3.7: Delete old useWorkspaceStore server state
+
+Strip `members[]`, `agents[]`, `skills[]`, `workspace`, `workspaces[]`, and all refresh/hydrate methods. Or delete entirely if fully migrated.
+
+---
+
+### Task 3.8: Update useWorkspaceId hook
+
+```typescript
+// Before (bridging):
+import { useWorkspaceStore } from "@/features/workspace";
+export function useWorkspaceId() {
+ return useWorkspaceStore((s) => s.workspace?.id ?? null);
+}
+
+// After:
+import { useWorkspaceClientStore } from "@core/workspace/store";
+export function useWorkspaceId() {
+ return useWorkspaceClientStore((s) => s.currentWorkspaceId);
+}
+```
+
+---
+
+### Task 3.9: Update useActorName hook
+
+```typescript
+// Before: reads members/agents from useWorkspaceStore
+// After: reads from TanStack Query cache
+import { useQuery } from "@tanstack/react-query";
+import { memberListOptions, agentListOptions } from "@core/workspace/queries";
+
+export function useActorName() {
+ const workspaceId = useWorkspaceId();
+ const { data: members = [] } = useQuery(memberListOptions(workspaceId));
+ const { data: agents = [] } = useQuery(agentListOptions(workspaceId));
+ // ... same logic, different data source
+}
+```
+
+**Run full check after Phase 3:**
+
+```bash
+make check
+```
+
+---
+
+## Phase 4: Runtimes Migration
+
+### Task 4.1: Create runtime queries + store + migrate
+
+**Files:**
+- Create: `apps/web/core/runtimes/queries.ts`
+- Create: `apps/web/core/runtimes/store.ts` — `{ selectedRuntimeId }` only
+- Modify: `apps/web/features/runtimes/store.ts` — delete server state
+- Modify: `apps/web/features/runtimes/components/runtimes-page.tsx`
+- Modify: `apps/web/features/realtime/use-realtime-sync.ts` — runtime events
+
+```typescript
+// core/runtimes/queries.ts
+export const runtimeKeys = {
+ all: ["runtimes"] as const,
+ list: (workspaceId: string | null) =>
+ [...runtimeKeys.all, "list", workspaceId] as const,
+ usage: (runtimeId: string, days?: number) =>
+ [...runtimeKeys.all, "usage", runtimeId, days] as const,
+ activity: (runtimeId: string) =>
+ [...runtimeKeys.all, "activity", runtimeId] as const,
+};
+```
+
+---
+
+### Task 4.2: Create task queries
+
+**Files:**
+- Create: `apps/web/core/tasks/queries.ts`
+
+For agent-live-card.tsx which fetches `getActiveTaskForIssue`, `listTaskMessages`, `listTasksByIssue`.
+
+---
+
+## Phase 5: Remaining Extraction + Cleanup
+
+### Task 5.1: Move shared/api/ → core/api/
+
+**Files:**
+- Move: `apps/web/shared/api/client.ts` → `apps/web/core/api/client.ts`
+- Move: `apps/web/shared/api/ws-client.ts` → `apps/web/core/api/ws-client.ts`
+- Move: `apps/web/shared/api/index.ts` → `apps/web/core/api/index.ts`
+
+**Step 1: Move files**
+
+**Step 2: Update ALL imports**
+
+```bash
+grep -rn "from.*@/shared/api" apps/web/
+```
+
+Replace `@/shared/api` with `@core/api` everywhere.
+
+**Step 3: Run typecheck**
+
+```bash
+pnpm typecheck
+```
+
+**Step 4: Commit**
+
+```bash
+git commit -m "refactor(core): move shared/api/ to core/api/"
+```
+
+---
+
+### Task 5.2: Move shared/types/ → core/types/
+
+**Files:**
+- Move entire `apps/web/shared/types/` → `apps/web/core/types/`
+
+**Step 1: Move files**
+
+**Step 2: Update ALL imports**
+
+```bash
+grep -rn "from.*@/shared/types" apps/web/
+```
+
+Replace `@/shared/types` with `@core/types` everywhere.
+
+**Step 3: Run typecheck + commit**
+
+---
+
+### Task 5.3: Move shared/logger.ts → core/logger.ts
+
+---
+
+### Task 5.4: Move auth store to core
+
+**Files:**
+- Move: `apps/web/features/auth/store.ts` → `apps/web/core/auth/store.ts`
+- Modify: `apps/web/features/auth/initializer.tsx` — update import
+- Modify: all `useAuthStore` consumers
+
+The auth store holds `user` (server state) and `isLoading`. In a full migration, `user` would become a TanStack Query query. But since auth is a singleton (not workspace-scoped) and rarely changes, it can stay as Zustand for now. The move to `core/` is purely organizational.
+
+---
+
+### Task 5.5: Extract WS sync to core/realtime/sync.ts
+
+Move the logic from `features/realtime/use-realtime-sync.ts` to `core/realtime/sync.ts`.
+
+The `WSProvider` component (contains JSX for React Context) stays in `features/realtime/provider.tsx`. The sync logic (pure hook, no JSX) moves to core.
+
+---
+
+### Task 5.6: Move data hooks to core
+
+After Tasks 1.5-1.7, the rewritten hooks (`useIssueTimeline`, `useIssueReactions`, `useIssueSubscribers`) are pure TanStack Query hooks with no JSX. Move them:
+
+- `features/issues/hooks/use-issue-timeline.ts` → `core/issues/hooks/use-issue-timeline.ts`
+- `features/issues/hooks/use-issue-reactions.ts` → `core/issues/hooks/use-issue-reactions.ts`
+- `features/issues/hooks/use-issue-subscribers.ts` → `core/issues/hooks/use-issue-subscribers.ts`
+
+---
+
+### Task 5.7: Move shared/hooks/use-file-upload.ts to core
+
+---
+
+### Task 5.8: Delete shared/ directory
+
+Verify nothing imports from `@/shared/`:
+
+```bash
+grep -rn "from.*@/shared" apps/web/
+```
+
+If clean, delete `apps/web/shared/`.
+
+---
+
+### Task 5.9: Create settings queries + mutations
+
+**Files:**
+- Create: `apps/web/core/settings/queries.ts` — `tokenQueries` for PAT list
+- Create: `apps/web/core/settings/mutations.ts` — `useUpdateMe`, `useCreatePAT`, `useRevokePAT`, member CRUD if not already in workspace mutations
+
+Migrate:
+- `apps/web/app/(dashboard)/settings/_components/account-tab.tsx`
+- `apps/web/app/(dashboard)/settings/_components/members-tab.tsx`
+- `apps/web/app/(dashboard)/settings/_components/workspace-tab.tsx`
+- `apps/web/app/(dashboard)/settings/_components/repositories-tab.tsx`
+- `apps/web/app/(dashboard)/settings/_components/tokens-tab.tsx`
+
+---
+
+### Task 5.10: Final verification
+
+**Run full check:**
+
+```bash
+make check
+```
+
+**Verify success criteria:**
+
+```bash
+# Zero Zustand stores with server data
+grep -rn "api\." apps/web/features/*/store.ts apps/web/features/*/stores/*.ts
+# Should return nothing (no API calls in stores)
+
+# All server data via TQ hooks
+grep -rn "useQuery\|useMutation" apps/web/core/
+# Should return many hits
+
+# No direct api.* in features/ (except editor file upload which is OK)
+grep -rn "api\.\(get\|list\|create\|update\|delete\|batch\|mark\|archive\)" apps/web/features/ apps/web/app/
+# Should return nothing or only edge cases
+
+# core/ has zero react-dom imports
+grep -rn "react-dom" apps/web/core/
+# Should return nothing
+
+# core/ has zero JSX
+grep -rn "tsx" apps/web/core/ --include="*.tsx"
+# Should return nothing (all files should be .ts, not .tsx)
+
+# shared/ directory deleted
+ls apps/web/shared/ 2>&1
+# Should return "No such file or directory"
+```
+
+---
+
+## Appendix A: Query Key Hierarchy
+
+```
+["issues"]
+ ["issues", "list", workspaceId]
+ ["issues", "detail", issueId]
+ ["issues", "timeline", issueId]
+ ["issues", "reactions", issueId]
+ ["issues", "subscribers", issueId]
+
+["inbox"]
+ ["inbox", "list", workspaceId]
+ ["inbox", "unread-count", workspaceId]
+
+["workspaces"]
+ ["workspaces", "list"]
+ ["workspaces", "detail", workspaceId]
+ ["workspaces", "members", workspaceId]
+ ["workspaces", "agents", workspaceId]
+ ["workspaces", "skills", workspaceId]
+
+["runtimes"]
+ ["runtimes", "list", workspaceId]
+ ["runtimes", "usage", runtimeId, days]
+ ["runtimes", "activity", runtimeId]
+
+["tasks"]
+ ["tasks", "active", issueId]
+ ["tasks", "messages", taskId]
+ ["tasks", "runs", issueId]
+
+["tokens"]
+ ["tokens", "list"]
+```
+
+---
+
+## Appendix B: WS Event → TanStack Query Mapping
+
+| WS Event | TQ Operation | Key |
+|---|---|---|
+| `issue:created` | `setQueryData` (append) | `issueKeys.list(wsId)` |
+| `issue:updated` | `setQueryData` (patch) | `issueKeys.list(wsId)` + `issueKeys.detail(id)` |
+| `issue:deleted` | `setQueryData` (filter) + `removeQueries` | `issueKeys.list(wsId)` + `issueKeys.detail(id)` |
+| `comment:created` | `setQueryData` (append) | `issueKeys.timeline(issueId)` |
+| `comment:updated` | `setQueryData` (patch) | `issueKeys.timeline(issueId)` |
+| `comment:deleted` | `setQueryData` (filter) | `issueKeys.timeline(issueId)` |
+| `activity:created` | `setQueryData` (append) | `issueKeys.timeline(issueId)` |
+| `reaction:added/removed` | `setQueryData` (patch) | `issueKeys.timeline(issueId)` |
+| `issue_reaction:added/removed` | `setQueryData` (patch) | `issueKeys.reactions(issueId)` |
+| `subscriber:added/removed` | `setQueryData` (patch) | `issueKeys.subscribers(issueId)` |
+| `inbox:new` | `setQueryData` (prepend) | `inboxKeys.list(wsId)` |
+| `inbox:read/archived/batch-*` | `invalidateQueries` | `inboxKeys.all` |
+| `member:added/updated/removed` | `invalidateQueries` | `workspaceKeys.members(wsId)` |
+| `agent:*` | `invalidateQueries` | `workspaceKeys.agents(wsId)` |
+| `skill:*` | `invalidateQueries` | `workspaceKeys.skills(wsId)` |
+| `workspace:updated` | `invalidateQueries` | `workspaceKeys.list()` |
+| `workspace:deleted` | `invalidateQueries` + side effect | `workspaceKeys.list()` |
+| `daemon:register` | `invalidateQueries` | `runtimeKeys.list(wsId)` |
+| **Reconnect** | `invalidateQueries` | All keys |
+
+---
+
+## Appendix C: Future Phase 6-7 Reference
+
+This plan intentionally structures `core/` to be easily extractable to `packages/core/` in Phase 6:
+
+1. **`core/` has zero `react-dom` dependency** — Desktop (Electron renderer) can import it directly.
+2. **`core/` has zero Next.js dependency** — No `next/navigation`, `next/link`, etc.
+3. **`core/` exports only `.ts` files** — No JSX, no components.
+4. **Import alias `@core/*`** — In Phase 6, change tsconfig to point `@multica/core/*` to `packages/core/`, or use package.json workspace imports.
+
+The Phase 6 extraction is essentially:
+```bash
+mv apps/web/core/ packages/core/
+# Update tsconfig aliases and package.json
+```
+
+Similarly, `components/ui/` + `components/common/` + `hooks/` + `lib/` will become `packages/ui/` in Phase 6.