> For AI agents: the complete documentation index is available at /llms.txt, the full documentation bundle is available at /llms-full.txt.

# Plan 06 — studio-api Migration Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended,逐 task fresh subagent) or `superpowers:executing-plans`. 这是 Phase 1 最大 caller migration plan(9 task);建议 subagent 模式,每 task 完成后 user review SQL 写法 + transaction wrap 是否到位再进下一 task。

**Goal:** 把 studio-api 6 个 task endpoint(close / reopen / postpone + 新 progress)和 3 个读路径(contacts list / leads KPI / dashboard attempt workload)收口到 `@retaintive/common/domain` 的 `buildTaskActionSQL()` / `buildCloseAllOpenSQL()` raw-SQL adapter。Phase 1 ship 后,studio-api 写 task 不再自拼 SQL,所有 mutation 走统一 state machine + Policy Guard。

**Architecture:** studio-api 是 Hono on AWS Lambda,数据访问层用 `@neondatabase/serverless` 的 `neon-http` driver(raw SQL tagged-template / `sql.query()` / `sql.transaction()`,**不用 Drizzle**)。所以本 plan 用 Plan 02 Task 6 交付的 `buildTaskActionSQL()` adapter,它返回 `{ sql: string; params: unknown[] }[]`,caller 必须用 `sql.transaction(statements.map(s => sql(s.sql, s.params)))` 包裹(B2 invariant)。

**Tech Stack:** TypeScript 5 / Hono 4 / `@neondatabase/serverless` neon-http / Zod 3 / Vitest 4 / AWS Lambda(Node 20)

**Spec source:** `docs/product-design/v2/unified-pipeline/implementation-plan/normative-spec.md` §4a-4i + §5.0 invariants + §5.2 integration tests

**Dependency:**

- Plan 01 merged(`@retaintive/common@1.1.0` schema delta:`task_progress_events` 表 + tasks 新字段)
- Plan 02 merged(`@retaintive/common@1.2.0` 含 `./domain` export:`buildTaskActionSQL` / `buildCloseAllOpenSQL`)
- 测试 Neon 已 apply Plan 01 migration SQL(否则 `WHERE status IN ('pending','open')` 跑得通但 `task_progress_events` 表不存在)

**关键 invariant 复读(每 task 都要记):**

- **B2 — `sql.transaction(statements.map(s => sql(s.sql, s.params)))`** 必须包裹 `buildTaskActionSQL()` 返回的 statements;**禁止** `for (const stmt of statements) await sql.query(stmt.sql, stmt.params)`(后者非原子,timeline 写一半失败 = 数据不一致)
- **过渡期 status 字符串用 `IN ('pending', 'open')`**(Plan 01 transitional schema 期间;Plan 07 final cutover 后 sweep 成 `status = 'open'`)
- **Phase 1 不删 `contacts.action_needed` 字段**,只改读路径(`SELECT c.action_needed` 暂留,Phase 2 才 DROP)
- **B3 dashboard attempt workload 改算 `task_progress_events`**,不算 `close_result = 'attempted'`

***

## File Structure

| File                                                             | Responsibility                                                                                                                                                | Action     |
| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
| `apps/api/package.json`                                          | bump `@retaintive/common` peer 1.0.x → 1.2.0                                                                                                                  | Modify     |
| `apps/api/src/lib/check-silent-reject.ts`                        | 共用 resultChecks 处理 helper(close / reopen / postpone / progress 调用),spec §3.1 line 251                                                                         | **Create** |
| `apps/api/src/routes/tasks/close.ts`                             | 改用 `buildTaskActionSQL({ action: 'close' })` + `sql.transaction()` 包裹 + `checkSilentReject()` + 拒绝 progress closeResult + 删 FOLLOW\_UP\_TASK\_TYPE 常量         | Modify     |
| `apps/api/src/routes/tasks/reopen.ts`                            | 改用 `buildTaskActionSQL({ action: 'reopen' })` + `sql.transaction()`                                                                                           | Modify     |
| `apps/api/src/routes/tasks/postpone.ts`                          | 改用 `buildTaskActionSQL({ action: 'update' })` + `sql.transaction()`                                                                                           | Modify     |
| `apps/api/src/routes/tasks/progress.ts`                          | **新建** `POST /v2/tasks/:taskId/progress` endpoint                                                                                                             | Create     |
| `apps/api/src/routes/tasks/index.ts`                             | 注册 `progressRoute`                                                                                                                                            | Modify     |
| `apps/api/src/routes/tasks/types.ts`                             | 删 `task_type: string` + `action_needed: boolean` / `action_needed_reason` fields from `TaskRow`                                                               | Modify     |
| `apps/api/src/routes/tasks/list.ts`                              | sweep `status = 'pending'` → `status IN ('pending', 'open')`(8 处);删 `t.task_type` / `t.action_needed` / `t.action_needed_reason` SELECT                       | Modify     |
| `apps/api/src/routes/tasks/events.ts`                            | sweep `status = 'pending'`(如有)→ `IN ('pending','open')`                                                                                                       | Modify     |
| `apps/api/src/routes/v3/contacts.ts`                             | 加 EXISTS 子查询 `has_open_task`;`c.action_needed` SELECT 保留(Phase 2 删)                                                                                           | Modify     |
| `apps/api/src/routes/v3/leads.ts`                                | KPI `action_needed` 改 EXISTS 子查询                                                                                                                              | Modify     |
| `apps/api/src/routes/v3/dashboard-multi-store.ts`                | `tasks_overdue` filter sweep `status = 'pending'` → `IN ('pending', 'open')`                                                                                  | Modify     |
| `apps/api/src/routes/v3/dashboard-staff.ts`(及其他 dashboard-\*.ts) | **B3** Attempt workload metric 改算 `task_progress_events`(`COUNT(*) FROM task_progress_events WHERE store_id=$1 AND occurred_at >= NOW() - INTERVAL '7 days'`) | Modify     |

测试文件(每个改造的 route 一个):

| Test file                                                                | 覆盖                                                                                                                                          |
| ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `apps/api/src/__tests__/routes/tasks-close.test.ts`                      | close 8 scenarios(happy / already-closed / progress closeResult 拒绝 / closeNote required / stale AI / cross-store / **transaction wrapper**) |
| `apps/api/src/__tests__/routes/tasks-progress.test.ts`                   | progress 9 scenarios(callId 幂等 / closed task / DNC / callback\_requested nextDueAt 必填 / channel enum / B2 transaction wrapper)              |
| `apps/api/src/__tests__/routes/tasks-reopen.test.ts`                     | reopen 5 scenarios(含 B2 transaction wrapper)                                                                                                |
| `apps/api/src/__tests__/routes/tasks-postpone.test.ts`                   | postpone 3 scenarios                                                                                                                        |
| `apps/api/src/__tests__/routes/v3-contacts-action-needed.test.ts`        | EXISTS 子查询 vs 旧 `action_needed` 等价 + transitional `IN ('pending','open')` 覆盖                                                                |
| `apps/api/src/__tests__/routes/v3-leads-kpi.test.ts`                     | leads KPI action\_needed EXISTS 等价                                                                                                          |
| `apps/api/src/__tests__/routes/dashboard-attempt-workload.test.ts`       | task\_progress\_events count + tenant isolation                                                                                             |
| `apps/api/src/__tests__/integration/tasks-mutations.integration.test.ts` | **跑真实 Neon**:close / reopen / progress 全 round-trip,verify SQL transaction rollback 行为                                                      |

***

## Task 1: Bump @retaintive/common 1.0.x → 1.2.0 + verify domain export 可 import

**Files:**

- Modify: `apps/api/package.json`

**Why this task first:** Task 2-9 全 import `@retaintive/common/domain` 的 `buildTaskActionSQL`。先 bump dep 让后续 task TypeScript 编译过。

### Step 1: Map what to verify

| # | Check                                                                                  | 为什么                                      |
| - | -------------------------------------------------------------------------------------- | ---------------------------------------- |
| 1 | `package.json` `dependencies` / `peerDependencies` 中 `@retaintive/common` 版本 ≥ `1.2.0` | Plan 02 ship 后 1.2.0 含 `./domain` export |
| 2 | `import { buildTaskActionSQL } from '@retaintive/common/domain'` 编译过                   | adapter 路径正确                             |
| 3 | `bun install` 后 `node_modules/@retaintive/common/dist/domain/index.js` 存在              | dist 真有 domain 子模块                       |

### Step 2: Implementation

```json
// apps/api/package.json
{
  "dependencies": {
    "@retaintive/common": "^1.2.0",  // 从 "*" 或 "^1.0.x" bump
    "...": "..."
  }
}
```

Monorepo 用 workspace 引用的话,`workspace:^1.2.0` 或 `*` 视 turbo / bun workspaces 设定 — 本 apps/api 当前是 `"*"`,可能要确认 packages/common 已 publish 1.2.0(本 monorepo 不是直接消费 retaintive/callytics-common,而是 npm consume),具体看 lockfile 当前怎么 resolve。

```bash
cd /Users/maxwsy/workspace/studio-website-monorepo && bun install
# Verify dist
ls node_modules/@retaintive/common/dist/domain/  # 应含 index.js + index.d.ts
# Quick smoke
echo 'import { buildTaskActionSQL } from "@retaintive/common/domain"; console.log(typeof buildTaskActionSQL);' > /tmp/smoke.ts
bun run /tmp/smoke.ts  # 期望输出 "function"
```

### Step 3: Test write + run

无独立 test 文件;Step 2 的 smoke import 算 verification。Type check:

```bash
cd /Users/maxwsy/workspace/studio-website-monorepo/apps/api && bun run typecheck
```

Expected: PASS(无 unresolved import error)。

### Step 4: Commit

```bash
git add apps/api/package.json
git commit -m "chore(api): bump @retaintive/common to 1.2.0 (domain modules)

Plan 06 Task 1 — 引入 buildTaskActionSQL / buildCloseAllOpenSQL adapter
为 Phase 1 close/reopen/postpone/progress 改造做准备。

Spec: normative-spec.md §3.2
"
```

***

## Task 1.5: Create `apps/api/src/lib/check-silent-reject.ts`

**Why**: normative-spec §3.1 line 251 — caller MUST 检查 `result.resultChecks`,0-row RETURNING = silent business reject(duplicate / stale\_proposal / task\_not\_open / task\_not\_closed),不能让 transaction "通过" 而真改 0 行。close / reopen / postpone / progress 都要这逻辑 — 抽 helper。Codex audit 2026-06-02 抓的 #1 issue。

**Files:**

- Create: `apps/api/src/lib/check-silent-reject.ts`

### Step 1: Implementation

```typescript
// apps/api/src/lib/check-silent-reject.ts
import type { ApplyResult } from '@retaintive/common/domain';
import { ConflictError, NotFoundError } from '../errors';  // 用 studio-api 现有 error types
import { logger } from '../logger';

interface CheckOpts {
  /** record_progress 的 idempotency_key duplicate 是预期 retry,只 log 不 throw */
  allowDuplicate?: boolean;
}

/**
 * 校验 sql.transaction() 返回的每个 statement result row count,
 * 跟 ApplyResult.resultChecks 比对。0 row = silent business reject。
 *
 * pattern source: normative-spec.md §3.1 line 251 contract
 */
export function checkSilentReject(
  resultChecks: ApplyResult['resultChecks'] | undefined,
  txResults: unknown[],  // neon-http sql.transaction 返回 row arrays
  ctx: { taskId: string; userId: string },
  action: 'close' | 'reopen' | 'postpone' | 'record_progress',
  opts: CheckOpts = {},
): void {
  for (const check of resultChecks ?? []) {
    const stmtResult = txResults[check.statementIndex];
    const rowCount = Array.isArray(stmtResult) ? stmtResult.length : 0;
    if (rowCount === 0) {
      logger.warn(`${action} silent reject — 0 rows`, {
        ...ctx,
        reason: check.zeroRowsReason,
      });
      if (opts.allowDuplicate && check.zeroRowsReason === 'duplicate') {
        continue;  // record_progress retry,预期幂等
      }
      // throw map per zeroRowsReason
      throw makeErrorFor(check.zeroRowsReason, action);
    }
  }
}

function makeErrorFor(reason: string | undefined, action: string): Error {
  switch (reason) {
    case 'task_not_open':
      return new ConflictError(`Task is not open (cannot ${action})`);
    case 'task_not_closed':
      return new ConflictError(`Task is not closed (cannot ${action})`);
    case 'stale_proposal':
      return new ConflictError('Stale request — please refresh and try again');
    case 'duplicate':
      return new ConflictError('Duplicate request');
    default:
      return new ConflictError(`Operation rejected: ${reason ?? 'unknown'}`);
  }
}
```

### Step 2: Commit

```bash
cd /Users/maxwsy/workspace/studio-website-monorepo/apps/api
git add src/lib/check-silent-reject.ts
git commit -m "feat(api): shared checkSilentReject helper

Phase 1 Plan 06 Task 1.5(Codex audit 2026-06-02 抓的 #1)。

close / reopen / postpone / record_progress 共用 — 校验 sql.transaction
返回的 row count 跟 ApplyResult.resultChecks 比对,0-row = silent business
reject(spec §3.1 line 251)。

之前每个 route 直接 await sql.transaction(...) 不读 resultChecks,
silent failure:duplicate / stale_proposal / task_not_open 都通过。
"
```

***

## Task 2: close.ts — buildTaskActionSQL + sql.transaction wrap + 拒绝 progress closeResult

**Files:**

- Modify: `apps/api/src/routes/tasks/close.ts`
- Create: `apps/api/src/__tests__/routes/tasks-close.test.ts`

### Step 1: Map test scenarios

| # | Scenario                                                                                                      | Expected                                                                                  | Why                                                                                 |
| - | ------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| 1 | Staff close open task with `closeResult='converted'`                                                          | 200 + UPDATE + timeline 通过 `sql.transaction()` 原子 commit                                  | Happy path                                                                          |
| 2 | Close already-closed task                                                                                     | 409 ConflictError                                                                         | `buildTaskActionSQL` 返回 `reject task_not_open`;route handler map 409                |
| 3 | Close with `closeResult='no_answer'`                                                                          | 400 + 提示 "Use POST /v2/tasks/:taskId/progress instead"                                    | spec §4a:close API 拒绝 progress closeResult,引导用 progress endpoint                    |
| 4 | Close with `closeResult='left_voicemail'` / `'callback_later'` / `'attempted'`                                | 同上 400                                                                                    | 4 个 progress closeResult 全拒绝                                                        |
| 5 | Close with `closeResult='other'` 无 closeNote                                                                  | 400 ValidationError                                                                       | Policy Guard `close_note_required`                                                  |
| 6 | AI close with stale `aiRunStartedAt`(task.updated\_at > aiRunStartedAt)                                       | 409 + reject reason `stale_proposal`(staff path 不传 aiRunStartedAt 不触发,Phase 1 不暴露给 staff) | §3.6 human authority guard via SQL conditional WHERE                                |
| 7 | Close taskId 跨 store(taskId 属于另一 storeId)                                                                     | 404 NotFoundError                                                                         | Policy Guard `hallucinated_task_id`                                                 |
| 8 | **B2 transaction wrapper** — route must call exactly one `sql.transaction()` with all Orchestrator statements | 200 + one transaction call; no loop of `sql.query()`                                      | caller 责任是使用现有 neon-http transaction wrapper; rollback behavior 由 DB integration 覆盖 |

**Sample test code style**(scenario 1 + 3 + 8 — 给 implementer 参考):

```typescript
// apps/api/src/__tests__/routes/tasks-close.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { testApp, seedTask, seedContact, getTaskRow } from '../helpers/test-app.ts';

describe('PATCH /v2/tasks/close', () => {
  beforeEach(async () => { await resetTestDb(); });

  it('closes open task atomically (UPDATE + timeline in same transaction)', async () => {
    const { taskId, contactPhone, storeId } = await seedTask({ status: 'open', typeCategory: 'lead_follow_up' });
    const res = await testApp.request(`/v2/tasks/close?taskId=${taskId}`, {
      method: 'PATCH',
      body: JSON.stringify({ storeId, staffName: 'Alice', closeResult: 'converted', note: '' }),
      headers: { 'Content-Type': 'application/json' },
    });
    expect(res.status).toBe(200);
    const updated = await getTaskRow(taskId);
    expect(updated.status).toBe('closed');
    // Verify timeline row written
    const timeline = await getTimelineRows(taskId);
    expect(timeline).toHaveLength(1);
    expect(timeline[0].event_type).toBe('task.status_changed');
  });

  it('rejects close with progress closeResult — routes user to progress endpoint', async () => {
    const { taskId, storeId } = await seedTask({ status: 'open' });
    const res = await testApp.request(`/v2/tasks/close?taskId=${taskId}`, {
      method: 'PATCH',
      body: JSON.stringify({ storeId, staffName: 'Alice', closeResult: 'no_answer', note: '' }),
      headers: { 'Content-Type': 'application/json' },
    });
    expect(res.status).toBe(400);
    const body = await res.json();
    expect(body.error).toMatch(/progress endpoint/i);
  });

  it('uses one sql.transaction for UPDATE + timeline (B2 wrapper)', async () => {
    const { taskId, contactPhone, storeId } = await seedTask({ status: 'open' });
    const sqlSpy = vi.spyOn(getNeonClient(), 'transaction');
    const res = await testApp.request(`/v2/tasks/close?taskId=${taskId}`, {
      method: 'PATCH',
      body: JSON.stringify({ storeId, staffName: 'Alice', closeResult: 'converted', note: '' }),
      headers: { 'Content-Type': 'application/json' },
    });
    expect(res.status).toBe(200);
    expect(sqlSpy).toHaveBeenCalledTimes(1);
    expect(sqlSpy.mock.calls[0]![0]).toHaveLength(1); // Orchestrator returns complete SQL unit(s)
    sqlSpy.mockRestore();
  });
});
```

### Step 2: Implementation

**Critical contract:**

- 删 line 22-27 `FOLLOW_UP_TASK_TYPE` / `FOLLOW_UP_TYPE_CATEGORY` / `FOLLOW_UP_STATUS` / `FOLLOW_UP_PRIORITY` / `FOLLOW_UP_SOURCE` 常量(no\_answer / left\_voicemail 不再走 close)
- 删 line 199-218 的 auto follow-up `INSERT INTO tasks ... INTERVAL '${daysToFollowUp} days'`(改走 progress endpoint)
- `buildContactTimelineInsertSQL` import 保留(其他 route 仍可能用),本 file 不再直接调用 — Orchestrator 内部自动写 timeline,跟 UPDATE 同 transaction

```typescript
// apps/api/src/routes/tasks/close.ts
import { Hono } from 'hono';
import { z } from 'zod';
import { buildTaskActionSQL } from '@retaintive/common/domain';
import { getNeonClient } from '../../services/neon.ts';
import { getAuthorizedStoreNeon } from '../../utils/store-authorization-neon.ts';
import { logger } from '../../utils/logger.ts';
import {
  ValidationError, NotFoundError, ForbiddenError, ConflictError,
} from '../../utils/errors.ts';
import type { CloseTaskResult } from './types.ts';

const PROGRESS_CLOSE_RESULTS = new Set([
  'no_answer', 'left_voicemail', 'callback_later', 'attempted',
]);

const querySchema = z.object({ taskId: z.string().uuid() });
const bodySchema = z.object({
  storeId: z.string().min(1),
  staffName: z.string().min(1).max(200),
  closeResult: z.string().min(1).max(100),
  note: z.string().max(1000).optional().default(''),
});

export const closeTaskRoute = new Hono()
  .patch('/close', async (c) => {
    const auth = c.get('auth');
    const userId = auth.userId;

    const queryParsed = querySchema.safeParse(c.req.query());
    if (!queryParsed.success) throw new ValidationError(queryParsed.error.message);
    const { taskId } = queryParsed.data;

    const body = await c.req.json();
    const bodyParsed = bodySchema.safeParse(body);
    if (!bodyParsed.success) throw new ValidationError(bodyParsed.error.message);
    const { storeId, staffName, closeResult, note } = bodyParsed.data;

    /*
     * Phase 1: close API does NOT accept progress closeResult values.
     * Frontend (Phase 1.5) should call POST /v2/tasks/:taskId/progress instead.
     * Temporary fallback until UI ships — return 400 with a helpful message.
     */
    if (PROGRESS_CLOSE_RESULTS.has(closeResult)) {
      throw new ValidationError(
        `closeResult='${closeResult}' is a progress event, not a close outcome. ` +
        `Use POST /v2/tasks/${taskId}/progress instead.`,
      );
    }

    const storeAuth = await getAuthorizedStoreNeon(userId, storeId);
    if (storeAuth.status === 'not_found') throw new NotFoundError('Store');
    if (storeAuth.status === 'forbidden') throw new ForbiddenError('No access to this store');
    if (storeAuth.role === 'VIEWER') throw new ForbiddenError('Viewers cannot modify tasks');

    const sql = getNeonClient();

    /*
     * Phase 1: route delegates to @retaintive/common/domain buildTaskActionSQL.
     * It handles Policy Guard (hallucination / state / DNC-allowed-for-close /
     * closeNote required) and returns transactional statements (UPDATE + timeline).
     */
    const result = await buildTaskActionSQL(
      {
        action: 'close',
        payload: { taskId, closeResult: closeResult as any, closeNote: note },
      },
      {
        storeId,
        actor: { type: 'staff', id: userId, name: staffName },
        db: sql as any,  // adapter accepts NeonHttpClient internally for SELECTs
      } as any,
    );

    if (result.status === 'reject') {
      switch (result.reason) {
        case 'hallucinated_task_id':
          throw new NotFoundError('Task');
        case 'task_not_open':
          throw new ConflictError('Task is already closed');
        case 'close_note_required':
          throw new ValidationError('closeNote required when closeResult=other');
        case 'stale_proposal':
          throw new ConflictError('Task was modified by another actor');
        case 'invalid_close_result':
          throw new ValidationError(`invalid closeResult: ${result.details}`);
        case 'store_mismatch':
          throw new NotFoundError('Task');  // 不泄露跨 store 存在
        default:
          throw new ValidationError(result.details ?? `rejected: ${result.reason}`);
      }
    }

    /*
     * B2 — sql.transaction wraps all statements (UPDATE + timeline INSERT)
     * into a single Postgres BEGIN/COMMIT. NEVER replace with a for-loop
     * of sql.query() — that is non-atomic and breaks audit consistency.
     *
     * neon-http sql.transaction 返回 each statement 的 result rows array,
     * 我们用它 + result.resultChecks 检测 silent business reject(spec §3.1
     * line 251):0-row RETURNING = stale_proposal / task_not_open / 等。
     */
    const txResults = await sql.transaction(result.statements.map(stmt => sql(stmt.sql, stmt.params)));

    for (const check of result.resultChecks ?? []) {
      const stmtResult = txResults[check.statementIndex];
      const rowCount = Array.isArray(stmtResult) ? stmtResult.length : 0;
      if (rowCount === 0) {
        // 0 rows = business invariant violated(stale snapshot / wrong state)。
        // close endpoint 这里翻译成 409 Conflict — 前端 staff 手动 close
        // 时碰到 race 才会触发(AI close 已在 Lambda path,不走 studio-api)。
        logger.warn('Close silent reject — 0 rows', {
          taskId, userId, reason: check.zeroRowsReason,
        });
        throw new ConflictError(
          check.zeroRowsReason === 'task_not_open'
            ? 'Task is already closed by another user'
            : 'Stale request — please refresh and try again',
        );
      }
    }

    logger.info('Task closed', { taskId, staffName, closeResult, userId });

    return c.json({
      data: {
        task_id: taskId,
        status: 'closed' as const,
        closed_at: new Date().toISOString(),
      } satisfies CloseTaskResult,
    });
  });
```

**注意点:**

1. `result.statements` 是 `{ sql: string; params: unknown[] }[]`,`sql(stmt.sql, stmt.params)` 是 neon-http tagged-template 的调用形式
2. handler 不返回 RETURNING 拿到的 task row(Orchestrator 内部 query 已 verify),直接构造 response shape;若前端依赖 `closed_at` 精确值,改成 transaction 后 SELECT 一次或让 adapter 返回 echo
3. `getAuthorizedStoreNeon` 是 studio-api 现有 auth gate,保留 — Orchestrator 的 Policy Guard 是补充层

### Step 3: Test write + run

Implementer 按 case map 8 scenarios 写测试。

```bash
cd /Users/maxwsy/workspace/studio-website-monorepo/apps/api && bun test src/__tests__/routes/tasks-close.test.ts
```

Expected: 8/8 PASS。

### Step 4: Commit

```bash
git add apps/api/src/routes/tasks/close.ts apps/api/src/__tests__/routes/tasks-close.test.ts
git commit -m "feat(api): tasks/close — buildTaskActionSQL + sql.transaction (B2) + 拒绝 progress closeResult

Plan 06 Task 2.

- 改用 @retaintive/common/domain buildTaskActionSQL 单接口
- B2 — sql.transaction(statements.map(...)) 包裹 UPDATE + timeline,原子提交
- 拒绝 closeResult ∈ {no_answer, left_voicemail, callback_later, attempted}
  改走 POST /v2/tasks/:taskId/progress (Phase 1 临时 fallback)
- 删 FOLLOW_UP_TASK_TYPE 常量 + auto follow-up INSERT(过渡到 progress endpoint)
- Policy Guard reject 映射:hallucinated_task_id → 404 / task_not_open → 409 /
  close_note_required → 400 / stale_proposal → 409

Spec: normative-spec.md §4a + §5.0 invariant 'closeResult 纯 outcome'
"
```

***

## Task 3: reopen.ts — buildTaskActionSQL action='reopen' + sql.transaction wrap

**Files:**

- Modify: `apps/api/src/routes/tasks/reopen.ts`
- Create: `apps/api/src/__tests__/routes/tasks-reopen.test.ts`

### Step 1: Map test scenarios

| # | Scenario                                                                | Expected                                                                                | Why                                           |
| - | ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------- |
| 1 | Reopen closed task                                                      | 200 + status='open' + close fields cleared + timeline `task.status_changed` 原子写         | Happy                                         |
| 2 | Reopen open task                                                        | 409 ConflictError                                                                       | `task_not_closed`                             |
| 3 | Reopen taskId 跨 store                                                   | 404                                                                                     | `hallucinated_task_id`                        |
| 4 | Reopen DNC contact 的 task                                               | 400 `dnc` reject                                                                        | spec §3.6:reopen 不在 DNC allowed 列(只 close 允许) |
| 5 | **B2 transaction wrapper** — route uses exactly one `sql.transaction()` | 200 + one transaction call containing all returned statements; no loop of `sql.query()` | 同 close,caller 只测 wrapper wiring              |

### Step 2: Implementation

跟 close.ts 同 pattern。改动 vs 现有 reopen.ts:

- 删 line 91-106 自拼的 `UPDATE tasks SET status='pending'` 整段
- 删 line 117-127 的 existing-row 二次 SELECT(Orchestrator 内部已做)
- 删 line 136-158 自拼的 `buildContactTimelineInsertSQL`(Orchestrator 内部写)
- handler 改成调 `buildTaskActionSQL({ action: 'reopen', payload: { taskId, reason } })`

```typescript
// apps/api/src/routes/tasks/reopen.ts (核心改动 — 省略 import / Zod schema)
const result = await buildTaskActionSQL(
  { action: 'reopen', payload: { taskId } },
  {
    storeId,
    actor: { type: 'staff', id: userId },
    db: sql as any,
  } as any,
);

if (result.status === 'reject') {
  switch (result.reason) {
    case 'hallucinated_task_id': throw new NotFoundError('Task');
    case 'task_not_closed': throw new ConflictError('Task is already open');
    case 'dnc': throw new ValidationError('Cannot reopen task for DNC contact');
    case 'store_mismatch': throw new NotFoundError('Task');
    default: throw new ValidationError(result.details ?? `rejected: ${result.reason}`);
  }
}

// B2 — sql.transaction wrap + resultChecks(spec §3.1 line 251)
const txResults = await sql.transaction(result.statements.map(stmt => sql(stmt.sql, stmt.params)));
checkSilentReject(result.resultChecks, txResults, { taskId, userId }, 'reopen');

return c.json({
  data: {
    task_id: taskId,
    status: 'open' as const,
    updated_at: new Date().toISOString(),
  } satisfies ReopenTaskResult,
});
```

**注意 response shape**:现有 reopen 返回 `status: 'pending'`,Phase 1 transitional 期间 schema 允许 `'pending' | 'open'`,但 final cutover 后只有 `'open'`。本 handler 直接返 `'open'`(写入的就是 `'open'`)。前端如果硬编码读 `'pending'`,需要 Phase 1.5 一并改 — issue 标 follow-up。

### Step 3: Test write + run

按 case map 5 scenarios 写,运行同 Task 2 pattern。

### Step 4: Commit

```bash
git add apps/api/src/routes/tasks/reopen.ts apps/api/src/__tests__/routes/tasks-reopen.test.ts
git commit -m "feat(api): tasks/reopen — buildTaskActionSQL action='reopen' + sql.transaction (B2)

Plan 06 Task 3.

Spec: normative-spec.md §4c
"
```

***

## Task 4: postpone.ts — buildTaskActionSQL action='update' + sql.transaction wrap

**Files:**

- Modify: `apps/api/src/routes/tasks/postpone.ts`
- Create: `apps/api/src/__tests__/routes/tasks-postpone.test.ts`

### Step 1: Map test scenarios

| # | Scenario                                                                | Expected                                                                                | Why             |
| - | ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | --------------- |
| 1 | Postpone open task to future dueAt                                      | 200 + UPDATE due\_at + timeline `task.due_at_changed`(实际 Orchestrator 用 `task.updated`) | Happy           |
| 2 | Postpone closed task                                                    | 409 ConflictError                                                                       | `task_not_open` |
| 3 | Postpone with dueAt in past                                             | 400 ValidationError(route-level check)                                                  | 现有逻辑保留          |
| 4 | **B2 transaction wrapper** — route uses exactly one `sql.transaction()` | 200 + one transaction call containing all returned statements; no loop of `sql.query()` | hard invariant  |

### Step 2: Implementation

```typescript
// apps/api/src/routes/tasks/postpone.ts (核心改动)
// 保留现有 route-level dueAt future check
if (newDueAt.getTime() <= Date.now()) {
  throw new ValidationError('dueAt must be in the future');
}

const result = await buildTaskActionSQL(
  {
    action: 'update',
    payload: { taskId, dueAt: newDueAt },  // 只改 dueAt
  },
  {
    storeId,
    actor: { type: 'staff', id: userId },
    db: sql as any,
  } as any,
);

if (result.status === 'reject') {
  switch (result.reason) {
    case 'hallucinated_task_id': throw new NotFoundError('Task');
    case 'task_not_open': throw new ConflictError('Cannot postpone a closed task');
    case 'dnc': throw new ValidationError('Cannot postpone task for DNC contact');
    case 'store_mismatch': throw new NotFoundError('Task');
    default: throw new ValidationError(result.details ?? `rejected: ${result.reason}`);
  }
}

// B2 — sql.transaction wrap + resultChecks(spec §3.1 line 251)
const txResults = await sql.transaction(result.statements.map(stmt => sql(stmt.sql, stmt.params)));
checkSilentReject(result.resultChecks, txResults, { taskId, userId }, 'postpone');

return c.json({
  data: {
    task_id: taskId,
    due_at: newDueAt.toISOString(),
    updated_at: new Date().toISOString(),
  },
});
```

### Step 3 + 4: Test + commit(同 Task 3 pattern)

```bash
git add apps/api/src/routes/tasks/postpone.ts apps/api/src/__tests__/routes/tasks-postpone.test.ts
git commit -m "feat(api): tasks/postpone — buildTaskActionSQL action='update' + sql.transaction (B2)

Plan 06 Task 4.

Spec: normative-spec.md §4c
"
```

***

## Task 5: 新建 progress.ts — POST /v2/tasks/:taskId/progress endpoint

**Files:**

- Create: `apps/api/src/routes/tasks/progress.ts`
- Modify: `apps/api/src/routes/tasks/index.ts`(注册 route)
- Create: `apps/api/src/__tests__/routes/tasks-progress.test.ts`

### Step 1: Map test scenarios

| # | Scenario                                                                | Expected                                                                                                                  | Why                                                                                                   |
| - | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| 1 | Record `no_answer` with `callId`                                        | 200 + progress event INSERT + tasks.attempt\_count += 1 + nextDueAt = NOW + 60min + timeline `task.progress_recorded` 原子写 | Happy phone path                                                                                      |
| 2 | Record same `(taskId, callId, progressType)` 第二次                        | 200 `{ ok: true, deduped: true }` —— CTE 保护 attempt\_count **不翻倍**                                                        | spec §3.2 B8 + §5.3 idempotency                                                                       |
| 3 | Record on closed task                                                   | 409 ConflictError `task_not_open`                                                                                         | Policy Guard state check                                                                              |
| 4 | Record on DNC contact                                                   | 400 ValidationError `dnc`                                                                                                 | spec §3.6 — record\_progress 不在 DNC allowed                                                           |
| 5 | Record `callback_requested` 无 `nextDueAt` body field                    | 400 ValidationError                                                                                                       | callback\_requested + follow\_up\_scheduled 的 `nextDueAtOverride` 必填(否则 dueAt 不变也无意义)— route 层 Zod 检查 |
| 6 | Record with `messageId`(SMS path)                                       | 同 happy + idempotency\_key 格式 `progress:taskId:message:M1:text_sent`                                                      | spec §3.2 4 source pattern                                                                            |
| 7 | Record with neither callId nor messageId(manual path)                   | 200 + idempotency\_key 含 `manual:actorId:occurredAt:progressType`                                                         | Manual evidence                                                                                       |
| 8 | Cross-store taskId                                                      | 404 NotFoundError                                                                                                         | Policy Guard `hallucinated_task_id`                                                                   |
| 9 | **B2 transaction wrapper** — route uses exactly one `sql.transaction()` | 200 + one transaction call containing progress SQL unit(s); no loop of `sql.query()`                                      | hard invariant                                                                                        |

### Step 2: Implementation

```typescript
// apps/api/src/routes/tasks/progress.ts (NEW FILE)
/**
 * POST /v2/tasks/:taskId/progress — Record progress event
 *
 * Records a non-terminal touch on an open task. Examples: no_answer,
 * left_voicemail, text_sent, callback_requested, follow_up_scheduled,
 * customer_considering.
 *
 * Phase 1 ship: 这是新端点,替代过去通过 close API + closeResult ∈ progress_set
 * 触发的"关旧建新 follow-up"模式。现在 progress 单独追踪,task 保持 open,
 * attempt_count 累加,dueAt 按 progressType 自动推后。
 *
 * Data: writes to task_progress_events + UPDATE tasks (attempt_count + due_at)
 *       + contact_timeline (task.progress_recorded)
 *       全部在单个 sql.transaction() 内原子提交 (B2)
 */
import { Hono } from 'hono';
import { z } from 'zod';
import { buildTaskActionSQL } from '@retaintive/common/domain';
import { getNeonClient } from '../../services/neon.ts';
import { getAuthorizedStoreNeon } from '../../utils/store-authorization-neon.ts';
import { logger } from '../../utils/logger.ts';
import {
  ValidationError, NotFoundError, ForbiddenError, ConflictError,
} from '../../utils/errors.ts';

const PROGRESS_TYPES = [
  'no_answer', 'left_voicemail', 'text_sent',
  'callback_requested', 'follow_up_scheduled', 'customer_considering',
] as const;
const CHANNELS = ['phone', 'sms', 'voicemail', 'email'] as const;

// progressType 需要 nextDueAt — Orchestrator 内部 computeNextDueAt 对这两类返回
// currentDueAt 不变;route-level 提示用户必传 override 避免 dueAt 不动 = bug
const REQUIRES_NEXT_DUE_AT = new Set([
  'callback_requested', 'follow_up_scheduled',
]);

const querySchema = z.object({ taskId: z.string().uuid() });
const bodySchema = z.object({
  storeId: z.string().min(1),
  progressType: z.enum(PROGRESS_TYPES),
  channel: z.enum(CHANNELS),
  callId: z.string().optional(),
  messageId: z.string().optional(),
  note: z.string().max(1000).optional(),
  nextDueAt: z.string().datetime().optional(),
}).refine(
  (b) => !REQUIRES_NEXT_DUE_AT.has(b.progressType) || !!b.nextDueAt,
  { message: 'nextDueAt is required for callback_requested / follow_up_scheduled' },
);

export const progressRoute = new Hono()
  .post('/:taskId/progress', async (c) => {
    const auth = c.get('auth');
    const userId = auth.userId;
    const staffName = auth.staffName ?? auth.userId;  // 视现有 auth shape

    const queryParsed = querySchema.safeParse({ taskId: c.req.param('taskId') });
    if (!queryParsed.success) throw new ValidationError(queryParsed.error.message);
    const { taskId } = queryParsed.data;

    const body = await c.req.json();
    const bodyParsed = bodySchema.safeParse(body);
    if (!bodyParsed.success) throw new ValidationError(bodyParsed.error.message);
    const { storeId, progressType, channel, callId, messageId, note, nextDueAt } = bodyParsed.data;

    const storeAuth = await getAuthorizedStoreNeon(userId, storeId);
    if (storeAuth.status === 'not_found') throw new NotFoundError('Store');
    if (storeAuth.status === 'forbidden') throw new ForbiddenError('No access to this store');
    if (storeAuth.role === 'VIEWER') throw new ForbiddenError('Viewers cannot modify tasks');

    const sql = getNeonClient();

    const result = await buildTaskActionSQL(
      {
        action: 'record_progress',
        payload: {
          taskId,
          progressType,
          channel,
          callId,
          messageId,
          note,
          nextDueAtOverride: nextDueAt ? new Date(nextDueAt) : undefined,
        },
      },
      {
        storeId,
        actor: { type: 'staff', id: userId, name: staffName },
        db: sql as any,
      } as any,
    );

    if (result.status === 'reject') {
      switch (result.reason) {
        case 'hallucinated_task_id': throw new NotFoundError('Task');
        case 'task_not_open': throw new ConflictError('Task is not open');
        case 'dnc': throw new ValidationError('Cannot record progress for DNC contact');
        case 'invalid_progress_type': throw new ValidationError(`invalid progressType: ${result.details}`);
        case 'duplicate':
          /*
           * Idempotency hit — ON CONFLICT idempotency_key DO NOTHING.
           * Return ok+deduped so client retries are silently safe.
           */
          return c.json({ data: { ok: true, deduped: true } });
        case 'store_mismatch': throw new NotFoundError('Task');
        default: throw new ValidationError(result.details ?? `rejected: ${result.reason}`);
      }
    }

    /*
     * B2 — sql.transaction wraps:
     *   1. CTE: INSERT task_progress_events ON CONFLICT (idempotency_key) DO NOTHING
     *           + UPDATE tasks SET attempt_count = attempt_count + 1 (only if INSERT fired)
     *   2. INSERT contact_timeline (task.progress_recorded)
     * Plus resultChecks(spec §3.1 line 251)— idempotency_key duplicate 是
     * 预期 retry,log warn 但不 throw(progress 是 idempotent operation)。
     */
    const txResults = await sql.transaction(result.statements.map(stmt => sql(stmt.sql, stmt.params)));
    checkSilentReject(result.resultChecks, txResults, { taskId, userId }, 'record_progress', { allowDuplicate: true });

    logger.info('Progress recorded', { taskId, progressType, channel, userId });

    return c.json({ data: { ok: true } });
  });
```

修改 `apps/api/src/routes/tasks/index.ts` 注册新 route:

```typescript
// apps/api/src/routes/tasks/index.ts (添加)
import { progressRoute } from './progress.ts';

const tasks = new Hono()
  // ... existing routes
  .route('/', progressRoute);  // POST /v2/tasks/:taskId/progress
```

### Step 3: Test write + run

按 9 scenarios 写测试。**关键 test #2 (idempotency)** 是 spec §5.3 invariant 直接验证:

```typescript
it('idempotent same (taskId, callId, progressType) — attempt_count not double-bumped', async () => {
  const { taskId, storeId } = await seedTask({ status: 'open', attempt_count: 0 });
  const body = {
    storeId,
    progressType: 'no_answer',
    channel: 'phone',
    callId: 'CALL_X',
  };
  const r1 = await testApp.request(`/v2/tasks/${taskId}/progress`, {
    method: 'POST', body: JSON.stringify(body),
    headers: { 'Content-Type': 'application/json' },
  });
  const r2 = await testApp.request(`/v2/tasks/${taskId}/progress`, {
    method: 'POST', body: JSON.stringify(body),
    headers: { 'Content-Type': 'application/json' },
  });
  expect(r1.status).toBe(200);
  expect(r2.status).toBe(200);
  const r2body = await r2.json();
  expect(r2body.data.deduped).toBe(true);

  const task = await getTaskRow(taskId);
  expect(task.attempt_count).toBe(1);  // ← CTE 保护:不翻倍

  const events = await getProgressEvents(taskId);
  expect(events).toHaveLength(1);  // ← 只 1 行 event
});
```

```bash
cd /Users/maxwsy/workspace/studio-website-monorepo/apps/api && bun test src/__tests__/routes/tasks-progress.test.ts
```

Expected: 8/8 PASS,**特别 #2 必须 PASS**(否则 B8 CTE 没 wire 通)。

### Step 4: Commit

```bash
git add apps/api/src/routes/tasks/progress.ts apps/api/src/routes/tasks/index.ts apps/api/src/__tests__/routes/tasks-progress.test.ts
git commit -m "feat(api): 新建 POST /v2/tasks/:taskId/progress endpoint

Plan 06 Task 5.

- 替代过去 close API + closeResult ∈ progress_set 的关旧建新 follow-up 模式
- 6 个 progressType (no_answer / left_voicemail / text_sent /
  callback_requested / follow_up_scheduled / customer_considering)
- 4 个 channel (phone / sms / voicemail / email)
- B2 — sql.transaction 包裹 CTE + timeline INSERT 原子
- B8 — CTE 保护 attempt_count 不在 ON CONFLICT DO NOTHING 时翻倍
- callback_requested / follow_up_scheduled 必须传 nextDueAt
  (否则 Orchestrator 不动 dueAt = 半 bug)

Spec: normative-spec.md §4b + §3.2 B8
"
```

***

## Task 6: status 字符串 sweep — list.ts / events.ts / dashboard-multi-store.ts(过渡期 IN ('pending','open'))

**Files:**

- Modify: `apps/api/src/routes/tasks/list.ts`(8 处 `status = 'pending'`)
- Modify: `apps/api/src/routes/tasks/events.ts`(如有 status filter)
- Modify: `apps/api/src/routes/v3/dashboard-multi-store.ts`(1 处 `status = 'pending'`)
- Modify: `apps/api/src/routes/tasks/types.ts`(`TaskRow.status` enum 加 `'open'`)
- Create: 无新 test 文件(Task 7 contacts/leads 一起覆盖)

### Step 1: Map test scenarios

不需要新独立 test 文件,本 task 是机械 sweep。验证手段:

- TypeScript 编译过(`TaskRow.status` enum 含 `'open'`)
- 集成 test(Task 9)跑过(过渡期 seed 既有 `'pending'` 又有 `'open'` 的 task,verify list / dashboard / contacts 都正确返回两类)

### Step 2: Implementation — 机械替换 + TaskRow 改 type

**`apps/api/src/routes/tasks/types.ts`** 改动:

```typescript
// 改 line 18:
status: 'pending' | 'open' | 'closed';  // transitional;Plan 07 cutover 后收缩到 'open' | 'closed'

// 删 line 16:
// task_type: string;   ← 删除

// 删 line 20-21:
// action_needed: boolean;
// action_needed_reason: string | null;
```

**`apps/api/src/routes/tasks/list.ts`** 机械替换:

| Line          | Before                                                           | After                                                        |
| ------------- | ---------------------------------------------------------------- | ------------------------------------------------------------ |
| 99            | `"t.status = 'pending'"`                                         | `"t.status IN ('pending', 'open')"`                          |
| 101           | `"t.status = 'pending'"`                                         | `"t.status IN ('pending', 'open')"`                          |
| 103           | `"t.status = 'pending'"`                                         | `"t.status IN ('pending', 'open')"`                          |
| 117           | `"t.status = 'pending'"`                                         | `"t.status IN ('pending', 'open')"`                          |
| 127           | `"t.status = 'pending'"`                                         | `"t.status IN ('pending', 'open')"`                          |
| 132           | `"t.status = 'pending'"`                                         | `"t.status IN ('pending', 'open')"`                          |
| 164           | `t.task_type,`                                                   | 删除该 line + 后续 SELECT 中所有 `task_type` 引用                      |
| 168           | `t.action_needed,`                                               | 删除                                                           |
| 169           | `t.action_needed_reason,`                                        | 删除                                                           |
| 239           | `CASE WHEN t.status = 'pending' THEN 0`                          | `CASE WHEN t.status IN ('pending', 'open') THEN 0`           |
| 253           | `WHERE status = 'pending'`                                       | `WHERE status IN ('pending', 'open')`                        |
| 254           | `WHERE status = 'pending' AND due_at < NOW()`                    | `WHERE status IN ('pending', 'open') AND due_at < NOW()`     |
| 255           | `WHERE status = 'pending' AND due_at BETWEEN ...`                | `WHERE status IN ('pending', 'open') AND due_at BETWEEN ...` |
| 256           | `WHERE status = 'pending' AND (due_at IS NULL OR due_at >= ...)` | `WHERE status IN ('pending', 'open') AND ...`                |
| 261, 269, 273 | 同 pattern,`status = 'pending'` → `status IN ('pending', 'open')` | 全部替换                                                         |

**`apps/api/src/routes/v3/dashboard-multi-store.ts`** line 191:

```sql
-- Before:
COUNT(*) FILTER (WHERE status = 'pending' AND due_at < NOW())::int AS tasks_overdue
-- After:
COUNT(*) FILTER (WHERE status IN ('pending', 'open') AND due_at < NOW())::int AS tasks_overdue
```

**`apps/api/src/routes/tasks/events.ts`**: grep status filter 后视情况改(当前已 grep 无 hit,只读路径 SELECT 不带 status filter — 不动)。

### Step 3: Test write + run

无新 test。Type check + 集成 test(Task 9)是 verification:

```bash
cd /Users/maxwsy/workspace/studio-website-monorepo/apps/api && bun run typecheck && bun test
```

Expected: PASS(`TaskRow.status` type 改后,所有消费 row\.status 的代码若硬编码 `'pending'` 会编译报错 — 必须修)。

**关键 grep verify** — commit 前跑:

```bash
cd /Users/maxwsy/workspace/studio-website-monorepo/apps/api && grep -rn "status = 'pending'\|status='pending'" src/routes/ | grep -v "// transitional"
```

Expected: 0 hit(全部已切到 `IN ('pending', 'open')`)。

### Step 4: Commit

```bash
git add apps/api/src/routes/tasks/list.ts apps/api/src/routes/tasks/types.ts apps/api/src/routes/v3/dashboard-multi-store.ts
git commit -m "refactor(api): sweep status='pending' → IN ('pending','open') + 删 task_type/action_needed

Plan 06 Task 6.

- 过渡期(Plan 01 transitional schema)所有读路径用 IN ('pending', 'open')
  覆盖既有 'pending' 行 + 新写入 'open' 行
- TaskRow.status type 加 'open'(过渡期 3-value;Plan 07 cutover 后收缩)
- 删 TaskRow.task_type / action_needed / action_needed_reason 字段
  (tasks 表字段退役;contacts.action_needed 字段保留待 Phase 2)
- list.ts 8 处 + dashboard-multi-store.ts 1 处 status sweep

Spec: normative-spec.md §4h + §6.2 transitional + §4i task_type 字段消费端清理
"
```

***

## Task 7: contacts.ts + leads.ts — has\_open\_task EXISTS 子查询替代 action\_needed 读路径

**Files:**

- Modify: `apps/api/src/routes/v3/contacts.ts`(line \~112 SELECT clause)
- Modify: `apps/api/src/routes/v3/leads.ts`(line \~209 KPI summary)
- Create: `apps/api/src/__tests__/routes/v3-contacts-action-needed.test.ts`
- Create: `apps/api/src/__tests__/routes/v3-leads-kpi.test.ts`

### Step 1: Map test scenarios

**`v3/contacts.ts` EXISTS 子查询等价 test**:

| # | Setup                                                                                                        | Expected                                              | Why                                          |
| - | ------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------- | -------------------------------------------- |
| 1 | Contact A 有 1 个 `status='open'` task → query                                                                 | `has_open_task=true` (or compat `action_needed=true`) | EXISTS 替代 action\_needed=true                |
| 2 | Contact B 无 task → query                                                                                     | `has_open_task=false`                                 | 无 task                                       |
| 3 | Contact C 有 1 个 `status='closed'` task → query                                                               | `has_open_task=false`                                 | closed 不算                                    |
| 4 | **Transitional 覆盖** — Contact D 有 1 个 `status='pending'` task(legacy)+ 1 个 `status='open'` task → query      | `has_open_task=true`(不漏 pending)                      | `IN ('pending', 'open')` covers transitional |
| 5 | Tenant isolation — Contact E 在 store\_A,Contact F 同 phone 在 store\_B,只 store\_B 有 open task → query store\_A | `has_open_task=false` for store\_A                    | store\_id WHERE 隔离                           |

**`v3/leads.ts` KPI etc**:

| # | Setup                                                                              | Expected                          | Why                                                                      |
| - | ---------------------------------------------------------------------------------- | --------------------------------- | ------------------------------------------------------------------------ |
| 1 | 3 contacts:A 有 open task,B 有 closed task,C 无 task                                  | `summary.action_needed = 1`(只算 A) | EXISTS 等价旧 `action_needed=true`                                          |
| 2 | Same phone 在 store\_A 和 store\_B → query store\_A only count store\_A 的 open tasks | KPI 不算 cross-store                | tenant isolation                                                         |
| 3 | Contact A 只有 legacy `status='pending'` task,Contact B 有 new `status='open'` task   | `summary.action_needed = 2`       | leads KPI 也必须用 `IN ('pending','open')`,不能只 contacts list 覆盖 transitional |

### Step 2: Implementation

**`apps/api/src/routes/v3/contacts.ts`** line 109-118 — 加 has\_open\_task 子查询:

```typescript
// Before (line 109-118):
SELECT
  c.phone, c.first_name, c.last_name, c.lifecycle_stage,
  c.lead_status, c.purchase_intent, c.has_card_on_file,
  c.do_not_contact, c.action_needed, c.has_open_complaint,
  c.last_activity_at, c.created_at,
  COALESCE(cs.call_count, 0)::integer AS call_count,
  COALESCE(ss.sms_count, 0)::integer AS sms_count
FROM contacts c
LEFT JOIN call_stats cs ON cs.phone = c.phone
LEFT JOIN sms_stats ss ON ss.phone = c.phone
WHERE ${conditions.join(' AND ')}

// After:
SELECT
  c.phone, c.first_name, c.last_name, c.lifecycle_stage,
  c.lead_status, c.purchase_intent, c.has_card_on_file,
  c.do_not_contact,
  c.action_needed,                              -- 字段保留,Phase 2 才 DROP
  /*
   * Phase 1: has_open_task EXISTS subquery is the new source of truth
   * for "this contact has work pending". Frontend should consume
   * has_open_task; c.action_needed kept for backward compat until
   * Phase 2 ships its DROP COLUMN migration.
   *
   * Transitional: status IN ('pending', 'open') covers both legacy
   * 'pending' rows (pre-Plan 01) and new 'open' rows (post-Plan 01).
   */
  EXISTS (
    SELECT 1 FROM tasks t
    WHERE t.contact_phone = c.phone
      AND t.store_id = c.store_id
      AND t.status IN ('pending', 'open')
  ) AS has_open_task,
  c.has_open_complaint,
  c.last_activity_at, c.created_at,
  COALESCE(cs.call_count, 0)::integer AS call_count,
  COALESCE(ss.sms_count, 0)::integer AS sms_count
FROM contacts c
LEFT JOIN call_stats cs ON cs.phone = c.phone
LEFT JOIN sms_stats ss ON ss.phone = c.phone
WHERE ${conditions.join(' AND ')}
```

记得也改 `ContactRow` interface 加 `has_open_task: boolean`。

**`apps/api/src/routes/v3/leads.ts`** line 207-214 KPI:

```typescript
// Before:
const summaryQuery = `
  SELECT
    COUNT(*)::integer AS total,
    COUNT(*) FILTER (WHERE c.action_needed = true)::integer AS action_needed,
    COUNT(*) FILTER (WHERE c.created_at >= NOW() - INTERVAL '1 day')::integer AS new_today,
    COUNT(*) FILTER (WHERE c.last_activity_at < NOW() - INTERVAL '5 days')::integer AS stale_count
  FROM contacts c
  WHERE ${whereClause}
`;

// After:
const summaryQuery = `
  SELECT
    COUNT(*)::integer AS total,
    /*
     * Phase 1: action_needed KPI changes source — old denormalized
     * c.action_needed boolean replaced by an EXISTS subquery against
     * tasks. Transitional IN ('pending', 'open') covers Plan 01 window.
     * COUNT(DISTINCT c.phone) because a phone may have multiple
     * (phone, store_id) contact rows in legacy data.
     */
    COUNT(DISTINCT c.phone) FILTER (WHERE EXISTS (
      SELECT 1 FROM tasks t
      WHERE t.contact_phone = c.phone
        AND t.store_id = c.store_id
        AND t.status IN ('pending', 'open')
    ))::integer AS action_needed,
    COUNT(*) FILTER (WHERE c.created_at >= NOW() - INTERVAL '1 day')::integer AS new_today,
    COUNT(*) FILTER (WHERE c.last_activity_at < NOW() - INTERVAL '5 days')::integer AS stale_count
  FROM contacts c
  WHERE ${whereClause}
`;
```

`LeadRow` interface 中 `action_needed: boolean | null` 字段 — Phase 1 留(仍 SELECT 行级 `c.action_needed` 给前端 fallback),Phase 2 一起删。

### Step 3: Test write + run

按 case map 写 5 + 3 = 8 test。**重点验证 transitional `IN ('pending', 'open')` 覆盖**(用户给的 case map 第 3 条核心要求)。

```bash
cd /Users/maxwsy/workspace/studio-website-monorepo/apps/api && bun test src/__tests__/routes/v3-contacts-action-needed.test.ts src/__tests__/routes/v3-leads-kpi.test.ts
```

Expected: PASS。

### Step 4: Commit

```bash
git add apps/api/src/routes/v3/contacts.ts apps/api/src/routes/v3/leads.ts apps/api/src/__tests__/routes/v3-contacts-action-needed.test.ts apps/api/src/__tests__/routes/v3-leads-kpi.test.ts
git commit -m "feat(api): v3/contacts + v3/leads — EXISTS 子查询替代 action_needed 读路径

Plan 06 Task 7.

- contacts list 加 has_open_task: EXISTS(SELECT 1 FROM tasks ...)
- leads KPI summary.action_needed 改 EXISTS 子查询(COUNT DISTINCT phone)
- 过渡期用 status IN ('pending', 'open') 覆盖 Plan 01 transitional window
- c.action_needed 字段 SELECT 保留(Phase 2 才 DROP COLUMN);
  c.action_needed_reason 同保留

Spec: normative-spec.md §4g
"
```

***

## Task 8: dashboard attempt workload 改算 task\_progress\_events(B3)

**Files:**

- Modify: `apps/api/src/routes/v3/dashboard-staff.ts`(或具体哪个 dashboard 文件含 attempt workload — 视具体 file 决定)
- 若 dashboard-analytics.ts / dashboard-overview\.ts 也有 attempt metric,同样改
- Create: `apps/api/src/__tests__/routes/dashboard-attempt-workload.test.ts`

### Step 1: Locate which dashboard file 含 attempt workload

```bash
grep -rn "attempt\|close_result = 'attempted'" apps/api/src/routes/v3/dashboard-*.ts
```

**当前现状(2026-06-02 grep)**:

- `dashboard-analytics.ts:119` `cancel_attempts` 是 **call subcategory 计算**(不是 task close\_result),保留不动
- 其他 dashboard-\*.ts 暂无 `close_result = 'attempted'` 用法

**说明**:Phase 1 之前,studio-api dashboard 没有暴露 "attempt workload" metric(用 task close\_result = attempted)。本 Task 8 主要是**为 Phase 1 后新增的 workload metric 提供正确 query 实现**。如果 dashboard PM 没要求新 metric,可以 defer 到 Phase 2(标 DEFERRED 在 doc 里)。

但 spec §4h B3 要求"口径迁移",意思是 **现有 metric 若以 `closeResult='attempted'` 为口径要改**。grep 没 hit 说明 studio-api 这部分本来就用 calls 的 cancel\_attempts 而不是 tasks。**Task 8 简化为:文档化口径 + 预埋一个 `getAttemptWorkload()` helper function 给 Phase 1.5 dashboard 用,但本 plan 不强制 wire 进 endpoint**。

### Step 2: Map test scenarios(helper function only)

| # | Scenario                                                         | Expected                                                            | Why                      |
| - | ---------------------------------------------------------------- | ------------------------------------------------------------------- | ------------------------ |
| 1 | Seed 5 progress events (3 by staff,2 by system,跨 7 天) → query    | `attempts=5, tasks_touched=correct, active_staff=N(staff distinct)` | spec §4h B3 SQL contract |
| 2 | Seed 1 progress event in store\_A,1 in store\_B → query store\_A | 不泄露 store\_B                                                        | Tenant isolation         |
| 3 | Seed progress event 10 天前 → query NOW - 7 days                   | 不计入(超出窗口)                                                           | Window filter            |

### Step 3: Implementation — 新建 helper

**`apps/api/src/routes/v3/dashboard-helpers/attempt-workload.ts`**(新建):

```typescript
/**
 * Attempt workload metric (Phase 1 口径迁移 — spec §4h B3).
 *
 * Phase 1 之前若 dashboard 用 tasks.close_result = 'attempted' 算 attempt
 * workload,Phase 1 这种 close_result 不存在了(progress 拆出来到 task_progress_events)。
 * 这里给出新口径的 helper。
 *
 * studio-api 当前 grep 无 close_result='attempted' 用法 — 本 helper 是预埋,
 * Phase 1.5 dashboard 改造时可直接消费。
 */
import { getNeonClient } from '../../../services/neon.ts';

export interface AttemptWorkloadRow {
  attempts: number;
  tasks_touched: number;
  active_staff: number;
}

export async function getAttemptWorkload(
  storeId: string,
  windowDays = 7,
): Promise<AttemptWorkloadRow> {
  const sql = getNeonClient();
  const rows = await sql.query(
    `SELECT
       COUNT(*)::int AS attempts,
       COUNT(DISTINCT task_id)::int AS tasks_touched,
       COUNT(DISTINCT actor_id) FILTER (WHERE actor_type = 'staff')::int AS active_staff
     FROM task_progress_events
     WHERE store_id = $1
       AND occurred_at >= NOW() - INTERVAL '${windowDays} days'`,
    [storeId],
  ) as unknown as AttemptWorkloadRow[];
  return rows[0] ?? { attempts: 0, tasks_touched: 0, active_staff: 0 };
}
```

注意 `INTERVAL '${windowDays} days'` 是 string interpolation 不是参数化 — windowDays 必须是 number(不可 SQL injection),route 调用方应 hardcode 或 enum,不接受 user input。

### Step 4: Test write + run

```typescript
// apps/api/src/__tests__/routes/dashboard-attempt-workload.test.ts
import { describe, it, expect } from 'vitest';
import { getAttemptWorkload } from '../../routes/v3/dashboard-helpers/attempt-workload.ts';
import { seedProgressEvent, resetTestDb } from '../helpers/test-db.ts';

describe('getAttemptWorkload', () => {
  it('counts progress events by store + actor distinct', async () => {
    await resetTestDb();
    const storeA = 'STORE_A';
    const taskA = 'task-A';
    const taskB = 'task-B';
    await seedProgressEvent({ taskId: taskA, storeId: storeA, actorType: 'staff', actorId: 'alice', progressType: 'no_answer', occurredAtDaysAgo: 0 });
    await seedProgressEvent({ taskId: taskA, storeId: storeA, actorType: 'staff', actorId: 'alice', progressType: 'left_voicemail', occurredAtDaysAgo: 1 });
    await seedProgressEvent({ taskId: taskB, storeId: storeA, actorType: 'staff', actorId: 'bob', progressType: 'no_answer', occurredAtDaysAgo: 2 });
    await seedProgressEvent({ taskId: taskB, storeId: storeA, actorType: 'system', actorId: 'cron', progressType: 'follow_up_scheduled', occurredAtDaysAgo: 3 });
    await seedProgressEvent({ taskId: taskA, storeId: storeA, actorType: 'system', actorId: 'cron', progressType: 'follow_up_scheduled', occurredAtDaysAgo: 4 });

    const r = await getAttemptWorkload(storeA, 7);
    expect(r.attempts).toBe(5);
    expect(r.tasks_touched).toBe(2);
    expect(r.active_staff).toBe(2);  // alice + bob distinct
  });

  it('tenant-isolates by store_id', async () => {
    await resetTestDb();
    await seedProgressEvent({ taskId: 'tA', storeId: 'STORE_A', actorType: 'staff', actorId: 'alice', progressType: 'no_answer' });
    await seedProgressEvent({ taskId: 'tB', storeId: 'STORE_B', actorType: 'staff', actorId: 'bob', progressType: 'no_answer' });
    const r = await getAttemptWorkload('STORE_A', 7);
    expect(r.attempts).toBe(1);
    expect(r.active_staff).toBe(1);
  });

  it('excludes events outside the window', async () => {
    await resetTestDb();
    await seedProgressEvent({ taskId: 'tA', storeId: 'STORE_A', actorType: 'staff', actorId: 'alice', progressType: 'no_answer', occurredAtDaysAgo: 10 });
    const r = await getAttemptWorkload('STORE_A', 7);
    expect(r.attempts).toBe(0);
  });
});
```

```bash
cd /Users/maxwsy/workspace/studio-website-monorepo/apps/api && bun test src/__tests__/routes/dashboard-attempt-workload.test.ts
```

Expected: 3/3 PASS。

### Step 5: Commit

```bash
git add apps/api/src/routes/v3/dashboard-helpers/attempt-workload.ts apps/api/src/__tests__/routes/dashboard-attempt-workload.test.ts
git commit -m "feat(api): dashboard helper getAttemptWorkload — 算 task_progress_events (B3)

Plan 06 Task 8.

- 新口径:Attempt workload = COUNT(*) FROM task_progress_events
  WHERE store_id=$1 AND occurred_at >= NOW() - INTERVAL '7 days'
- 返回 attempts / tasks_touched / active_staff (distinct staff actor_id)
- 本 plan 不强 wire 进现有 dashboard route(grep 无 close_result='attempted'
  当前用法);预埋给 Phase 1.5 dashboard 改造消费

Spec: normative-spec.md §4h B3
"
```

***

## Task 9: E2E integration test + final PR

**Files:**

- Create: `apps/api/src/__tests__/integration/tasks-mutations.integration.test.ts`

### Step 1: Map E2E scenarios(连真实测试 Neon)

| # | Scenario                                                                                                                                                           | Expected                                                                    | 覆盖哪条 invariant                       |
| - | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------- | ------------------------------------ |
| 1 | Seed open task → POST close(`closeResult='converted'`)→ SELECT tasks.status + timeline rows                                                                        | status='closed' + 1 timeline row(`task.status_changed`,actor\_type='staff') | spec §5.0 invariant 1 + B2 atomicity |
| 2 | Seed open task → POST progress(no\_answer, callId='C1')→ POST progress(同 callId,同 progressType)→ SELECT task.attempt\_count + progress events                      | attempt\_count=1(不翻倍),event 表 1 行                                           | spec §5.0 + B8 CTE invariant         |
| 3 | Seed open task in store\_A → POST close from store\_B's auth context → expect 404                                                                                  | NotFoundError                                                               | spec §5.0 tenant isolation           |
| 4 | Seed open task + DNC contact → POST progress → expect 400 `dnc`                                                                                                    | reject                                                                      | spec §3.6 DNC + I6 reopen 拒绝         |
| 5 | Seed closed task → POST reopen → SELECT status                                                                                                                     | status='open' + close fields cleared                                        | spec §5.2 reopen                     |
| 6 | Seed 3 open tasks (different typeCategory) for same contact → POST progress on each → all 3 attempt\_count++  + 3 timeline rows                                    | each task 独立 progress 不互扰                                                   | spec §5.0 + §5.2                     |
| 7 | List endpoint GET /v2/tasks?storeId=X → returns mix of `status='pending'` (legacy seed) + `status='open'` (new seed) → all returned + `summary.pending_count` 累加正确 | transitional `IN ('pending', 'open')` 工作                                    | spec §6.2 transitional + §4h sweep   |

### Step 2: Implementation outline

测试 setup 通过 `apps/api/scripts/integration-test.ts` 已有的 Neon test DB connection。Reset 步骤 + seed helpers 沿用现有 `__tests__/integration/neon-services.integration.test.ts` 的 pattern。

```typescript
// apps/api/src/__tests__/integration/tasks-mutations.integration.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { getNeonClient } from '../../services/neon.ts';
import {
  resetIntegrationDb,
  seedContact, seedTask, seedTimelineRow,
  callApi,
} from '../helpers/integration-helpers.ts';

describe('Phase 1 task mutations — E2E', () => {
  beforeEach(async () => { await resetIntegrationDb(); });

  it('close → UPDATE + timeline atomic', async () => {
    const { phone, storeId } = await seedContact({ doNotContact: false });
    const taskId = await seedTask({ phone, storeId, status: 'open', typeCategory: 'lead_follow_up' });
    const res = await callApi('PATCH', `/v2/tasks/close?taskId=${taskId}`, {
      storeId, staffName: 'Alice', closeResult: 'converted', note: '',
    });
    expect(res.status).toBe(200);
    const sql = getNeonClient();
    const [row] = await sql.query(`SELECT status FROM tasks WHERE task_id=$1`, [taskId]);
    expect(row.status).toBe('closed');
    const [tl] = await sql.query(`SELECT event_type FROM contact_timeline WHERE entity_id=$1`, [taskId]);
    expect(tl.event_type).toBe('task.status_changed');
  });

  it('progress idempotency — same (callId, progressType) twice → attempt_count not doubled (B8 CTE)', async () => {
    const { phone, storeId } = await seedContact({ doNotContact: false });
    const taskId = await seedTask({ phone, storeId, status: 'open', attempt_count: 0 });
    const body = { storeId, progressType: 'no_answer', channel: 'phone', callId: 'CALL_X' };
    await callApi('POST', `/v2/tasks/${taskId}/progress`, body);
    const r2 = await callApi('POST', `/v2/tasks/${taskId}/progress`, body);
    expect((await r2.json()).data.deduped).toBe(true);
    const sql = getNeonClient();
    const [row] = await sql.query(`SELECT attempt_count FROM tasks WHERE task_id=$1`, [taskId]);
    expect(row.attempt_count).toBe(1);
  });

  // ... 其他 5 scenarios
});
```

### Step 3: Test run

```bash
cd /Users/maxwsy/workspace/studio-website-monorepo/apps/api && bun run integration-test:neon
```

Expected: 7/7 PASS。

### Step 4: Final verification + PR

```bash
# 全 suite
cd /Users/maxwsy/workspace/studio-website-monorepo/apps/api && bun run check && bun test && bun run integration-test:neon

# Grep verify
grep -rn "status = 'pending'" apps/api/src/routes/ | grep -v "// transitional"
# Expected: 0 hit

grep -rn "buildContactTimelineInsertSQL" apps/api/src/routes/tasks/{close,reopen,postpone}.ts
# Expected: 0 hit (Orchestrator 内部已封)

grep -rn "task_type" apps/api/src/routes/tasks/
# Expected: 0 hit (字段退役)
```

如果全 pass,commit + push:

```bash
git add apps/api/src/__tests__/integration/tasks-mutations.integration.test.ts
git commit -m "test(api): Phase 1 E2E integration — close/progress/reopen 7 scenarios

Plan 06 Task 9.

- 跑真实测试 Neon
- 验 spec §5.0 invariants 全 hold:
  - tasks 二态(过渡期 pending 也接受)
  - progress 不 close task(attempt_count + 1,status 不变)
  - B8 CTE — 重复 progress 不翻倍 attempt_count
  - B2 — sql.transaction UPDATE + timeline 原子
  - Tenant isolation — 跨 store taskId 404
  - DNC hard stop — record_progress + reopen 拒绝

Spec: normative-spec.md §5.0 + §5.2 + §5.3
"
```

PR 用 `pr` skill 写中文 description:

```bash
git push -u origin <feature-branch>

# 用 pr skill 生成 body 后:
gh pr create --title "feat(api): Phase 1 — studio-api task mutations 收口到 @retaintive/common/domain" \
  --body-file /tmp/pr-body.md
```

***

## Self-Review Checklist

### Spec coverage

- ✅ §4a close.ts → Task 2(`buildTaskActionSQL` + `sql.transaction` + 拒绝 progress closeResult + 删 FOLLOW\_UP 常量 + 删 auto follow-up INSERT)
- ✅ §4b progress endpoint → Task 5(新建 POST /v2/tasks/:taskId/progress)
- ✅ §4c reopen.ts → Task 3
- ✅ §4c postpone.ts → Task 4(action='update')
- ✅ §4g contacts.ts / leads.ts EXISTS → Task 7
- ✅ §4h status 字符串 sweep → Task 6
- ✅ §4h B3 dashboard attempt workload helper → Task 8
- ✅ §4i task\_type / action\_needed 字段消费端清理 → Task 6(types.ts)+ Task 2(close.ts 删 INSERT)+ Task 7(SELECT 保留 c.action\_needed Phase 2 删)
- ✅ §5.0 invariant 'progress 不 close task' → Task 5 test #2 + Task 9 scenario #2
- ✅ §5.0 invariant 'closeResult 纯 outcome' → Task 2 test #3-4
- ✅ §5.0 invariant 'tenant isolation' → Task 2 test #7 + Task 9 scenario #3
- ✅ §5.0 invariant 'tasks 二态'(过渡期 3-value 兼容) → Task 6 `IN ('pending', 'open')`

### B2 transaction wrap invariant

- ✅ Task 2(close): `sql.transaction(result.statements.map(s => sql(s.sql, s.params)))` — 强制 + atomicity test scenario #8
- ✅ Task 3(reopen): 同
- ✅ Task 4(postpone): 同
- ✅ Task 5(progress): 同

### Type consistency

- ✅ `buildTaskActionSQL()` signature 跟 Plan 02 Task 6 一致:`(action: TaskAction, ctx: ActionContext) => Promise<ApplyResultSQL>`
- ✅ Import path `@retaintive/common/domain`(Plan 02 Task 7 barrel)
- ✅ `ApplyResultSQL` reject reason 字符串与 Plan 02 types.ts `RejectReason` union 完全 match(close.ts switch case 引用 13 个 reason 中的 6 个)
- ✅ Task 5 `ProgressEvidence` 4 source 跟 Plan 02 Task 2 `buildProgressIdempotencyKey` 一致(adapter 内部已用,handler 不需要直接调)

### No placeholders

- ✅ 每 task 给具体 file path + line range / new file 标 Create + 完整 path
- ✅ Test scenarios 全表格列清(无 "write tests for the above")
- ✅ SQL 写法完整给(EXISTS 子查询 / CTE / status IN 三种 pattern 都有 sample)
- ✅ 关键 invariant test 给 sample code(Task 2 #8 atomicity / Task 5 #2 idempotency / Task 8 #1 attempt workload)

### Execution order dependency

- Task 1 必须先(bump dep,后续 task 编译依赖)
- Task 2-5 可并行(独立 endpoint)— 但 Task 5 progress endpoint 的 contract 解释了 Task 2 为何拒绝 progress closeResult,逻辑上 Task 5 在 Task 2 前 explain 上下文更佳;实操可并行
- Task 6(sweep)依赖 Task 2-5 完成(否则 close/reopen/postpone 还会写 `'pending'`)
- Task 7 与 Task 6 可并行(改读路径,不影响写路径)
- Task 8 与 Task 7 可并行(dashboard helper 独立)
- Task 9 E2E 最后跑(verify 整 chain)

### Outstanding

- ⚠️ `auth.staffName` 当前 studio-api auth shape 是否含 staffName 字段需 verify(Task 5 progress.ts 引用)— 若无,改用 `getAuthorizedStoreNeon` 返回的 user info,或要求 body 传 staffName(跟 close 一致)
- ⚠️ Phase 1.5 前端改造 issue(`Status Update` 按钮 / Progress UI / `status='pending'` 硬编码消费)— 不在本 plan,follow-up 单独开
- ⚠️ Task 8 attempt workload helper 没 wire 进 dashboard endpoint(grep 无现有 attempt metric 用 close\_result='attempted')— 预埋,等 PM 决定 Phase 1.5 是否暴露

### LoC estimate

| Task                    | LoC                         |
| ----------------------- | --------------------------- |
| Task 1                  | \~5(package.json bump)      |
| Task 2 close.ts         | \~150 改 + \~250 test        |
| Task 3 reopen.ts        | \~120 改 + \~120 test        |
| Task 4 postpone.ts      | \~80 改 + \~100 test         |
| Task 5 progress.ts      | \~180 新 + \~280 test        |
| Task 6 sweep            | \~50 改(8+1 处)+ types.ts \~5 |
| Task 7 contacts/leads   | \~80 改 + \~220 test         |
| Task 8 dashboard helper | \~50 新 + \~150 test         |
| Task 9 E2E              | \~350 test                  |

**总计 \~2200-2500 LoC**(含 production code + test)。9 task 拆分匹配规模,subagent 模式每 task 1 fresh session 可控。

***

## Execution Handoff

**Option 1: Subagent-Driven**(推荐)— 每 Task 一 fresh subagent,review 之间 iterate。9 task 拆分明确,每 task 单独 commit + review。

**Option 2: Inline** — `superpowers:executing-plans` 跑全 9 task,checkpoint 之间停。Plan 06 是 Phase 1 最大 caller plan,LoC \~2200+,inline 模式 context 易 drift,**不推荐**。

**前置 dependency check**(开工前 verify):

```bash
# Plan 01 schema migration 在测试 Neon 已 apply?
psql $TEST_NEON_URL -c "\d task_progress_events" | head -5  # 表存在 → OK
psql $TEST_NEON_URL -c "SELECT executor_type, attempt_count, source_call_id FROM tasks LIMIT 1"  # 新字段存在 → OK

# Plan 02 published?
cd /Users/maxwsy/workspace/callytics-common && cat package.json | grep version  # 1.2.0 → OK
ls dist/domain/  # index.js + index.d.ts → OK

# studio-api 当前 @retaintive/common 版本
cd /Users/maxwsy/workspace/studio-website-monorepo && grep '"@retaintive/common"' apps/api/package.json
```

任一 check 不通过 → 不能开工,先 unblock dependency。

***

**Plan 06 结束 = Phase 1 studio-api caller migration 完成;Phase 1 整体只剩 contacts-analyzer / lead-processor / message-processor STOP 三个 Lambda caller(Plans 03-05)+ final schema cutover(Plan 07)。**
