# Workflow and Effort Engine Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Make DevTask, TestCase, and Bug status changes single-item only, move timestamp rules into workflow helpers, add TestCase estimates and weighted effort progress, and tighten AI estimates for AI-assisted delivery.

**Architecture:** Add small pure rule helpers in `apps/web/lib/` before changing stores or UI. Stores will call workflow helpers for single-item commands; UI will display derived effort from helpers instead of encoding status or time rules inline. AI estimate policy stays as a pure helper used when adopting AI drafts, while the server prompt/schema defines the upstream contract.

**Tech Stack:** Next.js 14, React, Zustand, TypeScript, Node `node:test` for web pure-function tests, NestJS/Jest for AI schema tests, existing PostgreSQL AppData persistence.

---

## File Structure

- Create `apps/web/lib/dev-task-workflow.ts`: DevTask single-item transition helper and create normalization.
- Create `apps/web/lib/dev-task-workflow.test.ts`: DevTask transition, timestamp, and AI draft default tests.
- Create `apps/web/lib/test-case-workflow.ts`: TestCase single-item transition helper and create normalization.
- Create `apps/web/lib/test-case-workflow.test.ts`: TestCase transition, timestamp, and reason cleanup tests.
- Create `apps/web/lib/bug-workflow.ts`: Bug single-item transition helper with log patch generation.
- Create `apps/web/lib/bug-workflow.test.ts`: Bug transition and timestamp tests.
- Create `apps/web/lib/work-effort-engine.ts`: shared estimate, actual, weighted progress aggregation.
- Create `apps/web/lib/work-effort-engine.test.ts`: weighted progress and rounding tests.
- Create `apps/web/lib/ai-estimation-policy.ts`: strict estimate ranges for AI-assisted dev/test work.
- Create `apps/web/lib/ai-estimation-policy.test.ts`: estimate clamp and default tests.
- Modify `apps/web/lib/dev-task.ts`: add `estimateHours`, make estimates prefer explicit hours, remove `estimateHours` from legacy discard marker.
- Modify `apps/web/lib/dev-task-transitions.ts`: keep delay detection only; stop using auto-start helpers.
- Modify `apps/web/stores/useDevTaskStore.ts`: remove auto-start scan, use workflow helper for `changeStatus`, normalize created tasks.
- Modify `apps/web/components/dev-task/DevTaskCreateModal.tsx`: persist computed `estimateHours`.
- Modify `apps/web/components/dev-task/DevTaskDetailDrawer.tsx`: import transitions from workflow constants if needed.
- Modify `apps/web/lib/test-case.ts`: add `estimateHours`, labels `待测试/测试中`, derived estimate/actual/progress helpers.
- Modify `apps/web/stores/useTestCaseStore.ts`: normalize `estimateHours`, use workflow helper for `changeStatus`.
- Modify `apps/web/components/test-case/TestCaseCreateModal.tsx`: add strict estimate input with 0.25h step.
- Modify `apps/web/components/test-case/TestCaseTab.tsx`: show total estimate, actual, weighted completion, and pass rate.
- Modify `apps/web/components/test-case/TestCaseRow.tsx`: show actual/estimate per row.
- Modify `apps/web/components/test-case/TestCaseDetailDrawer.tsx`: show estimate, actual start, completed time, and actual/estimate.
- Modify `apps/web/stores/useBugStore.ts`: use Bug workflow helper.
- Modify `packages/shared/src/agent.ts`: add `estimateHours` to `AgentTestCaseDraft`.
- Modify `apps/server/src/modules/ai/prompts/decompose.ts`: require TestCase estimate and tighten estimate rules.
- Modify `apps/server/src/modules/ai/ai.service.spec.ts`: assert TestCase estimate schema.
- Modify `apps/web/components/version/DecomposeReportModal.tsx`: clamp AI estimates, use `addWorkHours`, persist TestCase estimates.

---

### Task 1: Add Shared Work Effort Engine

**Files:**
- Create: `apps/web/lib/work-effort-engine.ts`
- Create: `apps/web/lib/work-effort-engine.test.ts`

- [ ] **Step 1: Write failing effort tests**

Create `apps/web/lib/work-effort-engine.test.ts`:

```ts
import test from 'node:test';
import assert from 'node:assert/strict';

import { aggregateWorkEffort, roundHalfHour } from './work-effort-engine';

test('roundHalfHour rounds to nearest half hour', () => {
  assert.equal(roundHalfHour(0.24), 0);
  assert.equal(roundHalfHour(0.25), 0.5);
  assert.equal(roundHalfHour(1.24), 1);
  assert.equal(roundHalfHour(1.25), 1.5);
});

test('aggregateWorkEffort calculates weighted progress by estimate', () => {
  const result = aggregateWorkEffort([
    { estimateHours: 1, actualHours: 0, progress: 0 },
    { estimateHours: 3, actualHours: 1.25, progress: 100 },
  ]);

  assert.equal(result.estimateHours, 4);
  assert.equal(result.actualHours, 1.5);
  assert.equal(result.progress, 75);
});

test('aggregateWorkEffort falls back to item average when estimates are zero', () => {
  const result = aggregateWorkEffort([
    { estimateHours: 0, actualHours: 0, progress: 50 },
    { estimateHours: 0, actualHours: 0, progress: 100 },
  ]);

  assert.equal(result.progress, 75);
});
```

- [ ] **Step 2: Run test and confirm failure**

Run:

```bash
pnpm --filter web test
```

Expected: FAIL because `work-effort-engine.ts` does not exist.

- [ ] **Step 3: Implement effort engine**

Create `apps/web/lib/work-effort-engine.ts`:

```ts
export interface WorkEffortItem {
  estimateHours: number;
  actualHours: number;
  progress: number;
}

export interface WorkEffortSummary {
  estimateHours: number;
  actualHours: number;
  progress: number;
}

export function roundHalfHour(hours: number): number {
  if (!Number.isFinite(hours) || hours <= 0) return 0;
  return Math.round(hours * 2) / 2;
}

export function aggregateWorkEffort(items: WorkEffortItem[]): WorkEffortSummary {
  if (items.length === 0) return { estimateHours: 0, actualHours: 0, progress: 0 };

  const estimateHours = roundHalfHour(items.reduce((sum, item) => sum + Math.max(0, item.estimateHours || 0), 0));
  const actualHours = roundHalfHour(items.reduce((sum, item) => sum + Math.max(0, item.actualHours || 0), 0));

  if (estimateHours <= 0) {
    const progress = Math.round(items.reduce((sum, item) => sum + item.progress, 0) / items.length);
    return { estimateHours, actualHours, progress };
  }

  const weighted = items.reduce((sum, item) => {
    const estimate = Math.max(0, item.estimateHours || 0);
    return sum + estimate * item.progress;
  }, 0);

  return {
    estimateHours,
    actualHours,
    progress: Math.round(weighted / estimateHours),
  };
}
```

- [ ] **Step 4: Run tests**

Run:

```bash
pnpm --filter web test
```

Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add apps/web/lib/work-effort-engine.ts apps/web/lib/work-effort-engine.test.ts
git commit -m "feat(工时): 增加通用工时汇总引擎"
```

---

### Task 2: Add DevTask Workflow Helper and Stop Auto Start

**Files:**
- Create: `apps/web/lib/dev-task-workflow.ts`
- Create: `apps/web/lib/dev-task-workflow.test.ts`
- Modify: `apps/web/lib/dev-task.ts`
- Modify: `apps/web/lib/dev-task-transitions.ts`
- Modify: `apps/web/stores/useDevTaskStore.ts`
- Modify: `apps/web/components/dev-task/DevTaskCreateModal.tsx`

- [ ] **Step 1: Write failing workflow tests**

Create `apps/web/lib/dev-task-workflow.test.ts`:

```ts
import test from 'node:test';
import assert from 'node:assert/strict';

import type { DevTask } from './dev-task';
import { applyDevTaskTransition, normalizeDevTaskOnCreate } from './dev-task-workflow';

function task(patch: Partial<DevTask> = {}): DevTask {
  return {
    id: 'task-1',
    taskNo: 'DEV-001',
    requirementId: 'req-1',
    title: '实现拖拽排序',
    categoryId: 'cat-frontend-interaction',
    assigneeId: 'Alice',
    priority: 'P2',
    expectedStartAt: '2026-06-25T01:00:00.000Z',
    expectedEndAt: '2026-06-25T02:00:00.000Z',
    status: 'todo',
    isBlocked: false,
    createdBy: 'PM',
    createdAt: '2026-06-25T00:00:00.000Z',
    updatedAt: '2026-06-25T00:00:00.000Z',
    ...patch,
  };
}

test('normalizeDevTaskOnCreate forces todo and clears actual timestamps for AI drafts', () => {
  const normalized = normalizeDevTaskOnCreate(task({
    status: 'in_progress',
    actualStartAt: '2026-06-25T01:00:00.000Z',
    actualEndAt: '2026-06-25T02:00:00.000Z',
    aiDraft: true,
  }));

  assert.equal(normalized.status, 'todo');
  assert.equal(normalized.actualStartAt, undefined);
  assert.equal(normalized.actualEndAt, undefined);
});

test('todo to in_progress writes actualStartAt from manual click time', () => {
  const result = applyDevTaskTransition(task(), 'in_progress', {
    now: new Date('2026-06-25T03:30:00.000Z'),
  });

  assert.equal(result.ok, true);
  assert.equal(result.patch?.status, 'in_progress');
  assert.equal(result.patch?.actualStartAt, '2026-06-25T03:30:00.000Z');
});

test('in_progress to testing does not write actualEndAt', () => {
  const result = applyDevTaskTransition(task({
    status: 'in_progress',
    actualStartAt: '2026-06-25T03:30:00.000Z',
  }), 'testing', {
    now: new Date('2026-06-25T04:00:00.000Z'),
  });

  assert.equal(result.ok, true);
  assert.equal(result.patch?.actualEndAt, undefined);
});

test('testing to submitted writes actualEndAt', () => {
  const result = applyDevTaskTransition(task({
    status: 'testing',
    actualStartAt: '2026-06-25T03:30:00.000Z',
  }), 'submitted', {
    now: new Date('2026-06-25T05:00:00.000Z'),
  });

  assert.equal(result.ok, true);
  assert.equal(result.patch?.actualEndAt, '2026-06-25T05:00:00.000Z');
});

test('invalid transition is rejected', () => {
  const result = applyDevTaskTransition(task(), 'submitted', {
    now: new Date('2026-06-25T05:00:00.000Z'),
  });

  assert.equal(result.ok, false);
  assert.match(result.message || '', /不允许/);
});
```

- [ ] **Step 2: Run test and confirm failure**

Run:

```bash
pnpm --filter web test
```

Expected: FAIL because `dev-task-workflow.ts` does not exist.

- [ ] **Step 3: Implement DevTask workflow helper**

Create `apps/web/lib/dev-task-workflow.ts`:

```ts
import type { DevTask, DevTaskStatus } from './dev-task';
import { canTransition } from './dev-task';

export interface DevTaskWorkflowResult {
  ok: boolean;
  patch?: Partial<DevTask>;
  message?: string;
}

export interface DevTaskTransitionOptions {
  now?: Date;
  delayReason?: string;
}

export function normalizeDevTaskOnCreate(task: DevTask): DevTask {
  return {
    ...task,
    status: 'todo',
    actualStartAt: undefined,
    actualEndAt: undefined,
  };
}

export function applyDevTaskTransition(
  task: DevTask,
  to: DevTaskStatus,
  options: DevTaskTransitionOptions = {},
): DevTaskWorkflowResult {
  if (!canTransition(task.status, to)) {
    return { ok: false, message: `不允许从「${task.status}」流转到「${to}」` };
  }

  const nowIso = (options.now ?? new Date()).toISOString();
  const patch: Partial<DevTask> = {
    status: to,
    aiDraft: false,
  };

  if (to === 'in_progress' && !task.actualStartAt) {
    patch.actualStartAt = nowIso;
    if (options.delayReason?.trim()) patch.delayReason = options.delayReason.trim();
  }

  if (to === 'submitted') {
    patch.actualEndAt = nowIso;
  }

  return { ok: true, patch };
}
```

- [ ] **Step 4: Add explicit DevTask estimate support**

Modify `apps/web/lib/dev-task.ts`:

```ts
export interface DevTask {
  // existing fields...
  expectedStartAt: string;
  expectedEndAt: string;
  estimateHours?: number;
  actualStartAt?: string;
  actualEndAt?: string;
  // existing fields...
}
```

Change `getEstimateHours`:

```ts
export function getEstimateHours(task: DevTask): number {
  if (typeof task.estimateHours === 'number' && task.estimateHours > 0) {
    return Math.round(task.estimateHours * 2) / 2;
  }
  if (!task.expectedStartAt || !task.expectedEndAt) return 0;
  return calcWorkHours(task.expectedStartAt, task.expectedEndAt);
}
```

Change `isLegacyTask` by removing `estimateHours` from the legacy marker list:

```ts
export function isLegacyTask(t: any): boolean {
  if (!t || typeof t !== 'object') return false;
  return (
    'startDate' in t ||
    'dueDate' in t ||
    'completedAt' in t ||
    'actualHours' in t ||
    'overdueReason' in t
  );
}
```

- [ ] **Step 5: Remove auto-start from DevTask fetch**

Modify `apps/web/stores/useDevTaskStore.ts` imports:

```ts
import { applyDevTaskTransition, normalizeDevTaskOnCreate } from '@/lib/dev-task-workflow';
```

Remove these imports:

```ts
import { findTasksToAutoStart, deriveActualStartAt } from '@/lib/dev-task-transitions';
```

Replace `fetchTasks` with:

```ts
fetchTasks: async () => {
  const cached = await loadStored();
  set({ tasks: cached ?? [] });
},
```

Update `createTask`:

```ts
const task: DevTask = normalizeDevTaskOnCreate({
  ...data,
  id: `task-${Date.now()}`,
  taskNo: generateTaskNo(list),
  isBlocked: false,
  createdAt: now,
  updatedAt: now,
} as DevTask);
```

Update `changeStatus`:

```ts
changeStatus: (id, to, opts) => {
  const task = get().tasks.find((t) => t.id === id);
  if (!task) return { ok: false, message: '任务不存在' };
  const result = applyDevTaskTransition(task, to, {
    now: new Date(),
    delayReason: opts?.delayReason,
  });
  if (!result.ok || !result.patch) return { ok: false, message: result.message };
  get().updateTask(id, result.patch);
  return { ok: true };
},
```

- [ ] **Step 6: Keep delay helper but stop using auto-start helpers**

Modify `apps/web/lib/dev-task-transitions.ts` so only `needsDelayReason` remains exported:

```ts
import type { DevTask } from './dev-task';

export function needsDelayReason(task: DevTask, now: Date = new Date()): boolean {
  if (task.status !== 'todo' || !task.expectedStartAt) return false;
  return now.getTime() > new Date(task.expectedStartAt).getTime();
}
```

- [ ] **Step 7: Persist manual DevTask estimate**

Modify `apps/web/components/dev-task/DevTaskCreateModal.tsx` in `createTask` payload:

```ts
estimateHours,
```

The payload section should include:

```ts
expectedStartAt: expectedStartISO,
expectedEndAt: expectedEndISO,
estimateHours,
actualStartAt: undefined,
actualEndAt: undefined,
status: 'todo',
```

- [ ] **Step 8: Run verification**

Run:

```bash
pnpm --filter web test
pnpm --filter web type-check
```

Expected: both pass, and DevTask fetch no longer changes task status automatically.

- [ ] **Step 9: Commit**

```bash
git add apps/web/lib/dev-task-workflow.ts apps/web/lib/dev-task-workflow.test.ts apps/web/lib/dev-task.ts apps/web/lib/dev-task-transitions.ts apps/web/stores/useDevTaskStore.ts apps/web/components/dev-task/DevTaskCreateModal.tsx
git commit -m "feat(开发任务): 状态流转接入单条工作流"
```

---

### Task 3: Add TestCase Workflow, Estimates, and Weighted Progress

**Files:**
- Create: `apps/web/lib/test-case-workflow.ts`
- Create: `apps/web/lib/test-case-workflow.test.ts`
- Modify: `apps/web/lib/test-case.ts`
- Modify: `apps/web/stores/useTestCaseStore.ts`

- [ ] **Step 1: Write failing TestCase workflow tests**

Create `apps/web/lib/test-case-workflow.test.ts`:

```ts
import test from 'node:test';
import assert from 'node:assert/strict';

import type { TestCase } from './test-case';
import { applyTestCaseTransition, normalizeTestCaseOnCreate } from './test-case-workflow';

function tc(patch: Partial<TestCase> = {}): TestCase {
  return {
    id: 'tc-1',
    caseNo: 'TC-001',
    versionId: 'version-1',
    title: '拖拽排序正常',
    description: '验证拖拽排序',
    categoryId: 'cat-test-functional',
    priority: 'P2',
    status: 'pending',
    estimateHours: 0.5,
    createdBy: 'QA',
    createdAt: '2026-06-25T00:00:00.000Z',
    updatedAt: '2026-06-25T00:00:00.000Z',
    ...patch,
  };
}

test('normalizeTestCaseOnCreate forces pending and clears actual timestamps', () => {
  const result = normalizeTestCaseOnCreate(tc({
    status: 'running',
    startedAt: '2026-06-25T01:00:00.000Z',
    completedAt: '2026-06-25T02:00:00.000Z',
  }));

  assert.equal(result.status, 'pending');
  assert.equal(result.startedAt, undefined);
  assert.equal(result.completedAt, undefined);
});

test('pending to running writes startedAt', () => {
  const result = applyTestCaseTransition(tc(), 'running', {
    now: new Date('2026-06-25T01:00:00.000Z'),
  });

  assert.equal(result.ok, true);
  assert.equal(result.patch?.startedAt, '2026-06-25T01:00:00.000Z');
});

test('running to passed writes completedAt', () => {
  const result = applyTestCaseTransition(tc({
    status: 'running',
    startedAt: '2026-06-25T01:00:00.000Z',
  }), 'passed', {
    now: new Date('2026-06-25T02:00:00.000Z'),
  });

  assert.equal(result.ok, true);
  assert.equal(result.patch?.completedAt, '2026-06-25T02:00:00.000Z');
});

test('failed back to running clears failure and completion reason', () => {
  const result = applyTestCaseTransition(tc({
    status: 'failed',
    startedAt: '2026-06-25T01:00:00.000Z',
    completedAt: '2026-06-25T02:00:00.000Z',
    failReason: '排序未保存',
  }), 'running', {
    now: new Date('2026-06-25T03:00:00.000Z'),
  });

  assert.equal(result.ok, true);
  assert.equal(result.patch?.startedAt, undefined);
  assert.equal(result.patch?.completedAt, undefined);
  assert.equal(result.patch?.failReason, undefined);
});

test('invalid transition is rejected', () => {
  const result = applyTestCaseTransition(tc(), 'passed', {
    now: new Date('2026-06-25T02:00:00.000Z'),
  });

  assert.equal(result.ok, false);
});
```

- [ ] **Step 2: Run test and confirm failure**

Run:

```bash
pnpm --filter web test
```

Expected: FAIL because `test-case-workflow.ts` does not exist.

- [ ] **Step 3: Implement TestCase workflow helper**

Create `apps/web/lib/test-case-workflow.ts`:

```ts
import type { TestCase, TestCaseStatus } from './test-case';
import { canTcTransition } from './test-case';

export interface TestCaseWorkflowResult {
  ok: boolean;
  patch?: Partial<TestCase>;
  message?: string;
}

export interface TestCaseTransitionOptions {
  now?: Date;
  failReason?: string;
  blockReason?: string;
}

export function normalizeTestCaseOnCreate(testCase: TestCase): TestCase {
  return {
    ...testCase,
    status: 'pending',
    startedAt: undefined,
    completedAt: undefined,
    executedAt: undefined,
    failReason: undefined,
    blockReason: undefined,
  };
}

export function applyTestCaseTransition(
  testCase: TestCase,
  to: TestCaseStatus,
  options: TestCaseTransitionOptions = {},
): TestCaseWorkflowResult {
  if (!canTcTransition(testCase.status, to)) {
    return { ok: false, message: `不允许从「${testCase.status}」流转到「${to}」` };
  }

  const nowIso = (options.now ?? new Date()).toISOString();
  const patch: Partial<TestCase> = {
    status: to,
    aiDraft: false,
    executedAt: nowIso,
  };

  if (to === 'running') {
    if (!testCase.startedAt) patch.startedAt = nowIso;
    patch.completedAt = undefined;
    patch.failReason = undefined;
    patch.blockReason = undefined;
  }

  if (to === 'passed' || to === 'failed' || to === 'blocked') {
    patch.completedAt = nowIso;
  }

  if (to === 'failed' && options.failReason?.trim()) patch.failReason = options.failReason.trim();
  if (to === 'blocked' && options.blockReason?.trim()) patch.blockReason = options.blockReason.trim();

  return { ok: true, patch };
}
```

- [ ] **Step 4: Add TestCase estimate and weighted helpers**

Modify `apps/web/lib/test-case.ts`:

```ts
import { aggregateWorkEffort } from './work-effort-engine';
```

Add `estimateHours?: number`:

```ts
export interface TestCase {
  // existing fields...
  status: TestCaseStatus;
  estimateHours?: number;
  startedAt?: string;
  completedAt?: string;
  // existing fields...
}
```

Change status labels:

```ts
export const TEST_CASE_STATUS_LABEL: Record<TestCaseStatus, string> = {
  pending: '待测试',
  running: '测试中',
  passed: '通过',
  failed: '不通过',
  blocked: '阻塞',
};
```

Update `normalizeTestCase`:

```ts
estimateHours: typeof testCase.estimateHours === 'number' && testCase.estimateHours > 0 ? testCase.estimateHours : 0.5,
```

Add progress and estimate helpers:

```ts
export const TEST_CASE_STATUS_PROGRESS: Record<TestCaseStatus, number> = {
  pending: 0,
  running: 50,
  passed: 100,
  failed: 100,
  blocked: 100,
};

export function getTestCaseEstimateHours(tc: TestCase): number {
  return typeof tc.estimateHours === 'number' && tc.estimateHours > 0
    ? Math.round(tc.estimateHours * 2) / 2
    : 0.5;
}

export function aggregateTestCaseHours(cases: TestCase[], now: Date = new Date()): { estimate: number; actual: number } {
  const summary = aggregateWorkEffort(cases.map((c) => ({
    estimateHours: getTestCaseEstimateHours(c),
    actualHours: getTestCaseActualHours(c, now),
    progress: TEST_CASE_STATUS_PROGRESS[c.status],
  })));
  return { estimate: summary.estimateHours, actual: summary.actualHours };
}
```

Update `calcTestProgress` to keep the existing return shape but make `completionRate` weighted:

```ts
export function calcTestProgress(cases: TestCase[]): { total: number; executed: number; passed: number; failed: number; blocked: number; passRate: number; completionRate: number } {
  const total = cases.length;
  if (total === 0) return { total: 0, executed: 0, passed: 0, failed: 0, blocked: 0, passRate: 0, completionRate: 0 };
  const passed = cases.filter((c) => c.status === 'passed').length;
  const failed = cases.filter((c) => c.status === 'failed').length;
  const blocked = cases.filter((c) => c.status === 'blocked').length;
  const executed = passed + failed + blocked;
  const passRate = (passed + failed) > 0 ? Math.round((passed / (passed + failed)) * 100) : 0;
  const effort = aggregateWorkEffort(cases.map((c) => ({
    estimateHours: getTestCaseEstimateHours(c),
    actualHours: getTestCaseActualHours(c),
    progress: TEST_CASE_STATUS_PROGRESS[c.status],
  })));
  return { total, executed, passed, failed, blocked, passRate, completionRate: effort.progress };
}
```

- [ ] **Step 5: Update TestCase store to use workflow**

Modify `apps/web/stores/useTestCaseStore.ts` imports:

```ts
import { applyTestCaseTransition, normalizeTestCaseOnCreate } from '@/lib/test-case-workflow';
```

Update `createTestCase`:

```ts
const tc: TestCase = normalizeTestCaseOnCreate({
  ...data,
  id: `tc-${Date.now()}`,
  caseNo: generateCaseNo(list),
  status: 'pending',
  estimateHours: data.estimateHours ?? 0.5,
  createdAt: now,
  updatedAt: now,
} as TestCase);
```

Update `changeStatus`:

```ts
changeStatus: (id, to, extra) => {
  const tc = get().testCases.find((c) => c.id === id);
  if (!tc) return { ok: false, message: '用例不存在' };
  const result = applyTestCaseTransition(tc, to, {
    now: new Date(),
    failReason: extra?.failReason,
    blockReason: extra?.blockReason,
  });
  if (!result.ok || !result.patch) return { ok: false, message: result.message };
  get().updateTestCase(id, result.patch);
  return { ok: true };
},
```

- [ ] **Step 6: Run verification**

Run:

```bash
pnpm --filter web test
pnpm --filter web type-check
```

Expected: both pass.

- [ ] **Step 7: Commit**

```bash
git add apps/web/lib/test-case-workflow.ts apps/web/lib/test-case-workflow.test.ts apps/web/lib/test-case.ts apps/web/stores/useTestCaseStore.ts
git commit -m "feat(测试用例): 状态流转接入单条工作流"
```

---

### Task 4: Add Bug Workflow Helper

**Files:**
- Create: `apps/web/lib/bug-workflow.ts`
- Create: `apps/web/lib/bug-workflow.test.ts`
- Modify: `apps/web/stores/useBugStore.ts`

- [ ] **Step 1: Write failing Bug workflow tests**

Create `apps/web/lib/bug-workflow.test.ts`:

```ts
import test from 'node:test';
import assert from 'node:assert/strict';

import type { Bug } from './bug';
import { applyBugTransition } from './bug-workflow';

function bug(patch: Partial<Bug> = {}): Bug {
  return {
    id: 'bug-1',
    bugNo: 'BUG-001',
    versionId: 'version-1',
    testCaseId: 'tc-1',
    title: '排序未保存',
    description: '拖拽后刷新丢失',
    severity: 'major',
    priority: 'P1',
    reportedBy: 'QA',
    assigneeId: 'Dev',
    status: 'open',
    logs: [],
    createdAt: '2026-06-25T00:00:00.000Z',
    updatedAt: '2026-06-25T00:00:00.000Z',
    ...patch,
  };
}

test('open to fixing changes only one bug patch', () => {
  const result = applyBugTransition(bug(), 'fixing', 'Dev', {
    now: new Date('2026-06-25T01:00:00.000Z'),
  });

  assert.equal(result.ok, true);
  assert.equal(result.patch?.status, 'fixing');
  assert.equal(result.patch?.logs?.length, 1);
});

test('fixing to fixed writes resolvedAt and resolution', () => {
  const result = applyBugTransition(bug({ status: 'fixing' }), 'fixed', 'Dev', {
    now: new Date('2026-06-25T02:00:00.000Z'),
    resolution: '补充保存接口',
  });

  assert.equal(result.ok, true);
  assert.equal(result.patch?.resolvedAt, '2026-06-25T02:00:00.000Z');
  assert.equal(result.patch?.resolution, '补充保存接口');
});

test('verifying to closed writes closedAt', () => {
  const result = applyBugTransition(bug({ status: 'verifying' }), 'closed', 'QA', {
    now: new Date('2026-06-25T03:00:00.000Z'),
  });

  assert.equal(result.ok, true);
  assert.equal(result.patch?.closedAt, '2026-06-25T03:00:00.000Z');
});

test('invalid bug transition is rejected', () => {
  const result = applyBugTransition(bug(), 'closed', 'QA');
  assert.equal(result.ok, false);
});
```

- [ ] **Step 2: Run test and confirm failure**

Run:

```bash
pnpm --filter web test
```

Expected: FAIL because `bug-workflow.ts` does not exist.

- [ ] **Step 3: Implement Bug workflow helper**

Create `apps/web/lib/bug-workflow.ts`:

```ts
import type { Bug, BugLog, BugStatus } from './bug';
import { canBugTransition } from './bug';

export interface BugTransitionOptions {
  now?: Date;
  resolution?: string;
}

export interface BugWorkflowResult {
  ok: boolean;
  patch?: Partial<Bug>;
  message?: string;
}

function makeBugLog(
  action: BugLog['action'],
  operator: string,
  nowIso: string,
  from?: string,
  to?: string,
  remark?: string,
): BugLog {
  return {
    id: `log-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
    action,
    fromValue: from,
    toValue: to,
    operator,
    remark,
    createdAt: nowIso,
  };
}

export function applyBugTransition(
  bug: Bug,
  to: BugStatus,
  operator: string,
  options: BugTransitionOptions = {},
): BugWorkflowResult {
  if (!canBugTransition(bug.status, to)) {
    return { ok: false, message: `不允许从「${bug.status}」流转到「${to}」` };
  }

  const nowIso = (options.now ?? new Date()).toISOString();
  const patch: Partial<Bug> = { status: to };

  if (to === 'fixed') patch.resolvedAt = nowIso;
  if (to === 'closed') patch.closedAt = nowIso;
  if (options.resolution?.trim()) patch.resolution = options.resolution.trim();

  const log = makeBugLog(
    to === 'fixed' ? 'resolve' : 'status_change',
    operator,
    nowIso,
    bug.status,
    to,
    options.resolution,
  );
  patch.logs = [...(bug.logs || []), log];

  return { ok: true, patch };
}
```

- [ ] **Step 4: Use workflow in Bug store**

Modify `apps/web/stores/useBugStore.ts` imports:

```ts
import { applyBugTransition } from '@/lib/bug-workflow';
```

Keep local `makeLog` for create/transfer, or rename it to `makeStoreLog` so it does not conflict.

Replace `changeStatus`:

```ts
changeStatus: (id, to, operator, extra) => {
  const bug = get().bugs.find((b) => b.id === id);
  if (!bug) return { ok: false, message: 'Bug不存在' };
  const result = applyBugTransition(bug, to, operator, {
    now: new Date(),
    resolution: extra?.resolution,
  });
  if (!result.ok || !result.patch) return { ok: false, message: result.message };
  get().updateBug(id, result.patch);
  return { ok: true };
},
```

- [ ] **Step 5: Run verification**

Run:

```bash
pnpm --filter web test
pnpm --filter web type-check
```

Expected: both pass.

- [ ] **Step 6: Commit**

```bash
git add apps/web/lib/bug-workflow.ts apps/web/lib/bug-workflow.test.ts apps/web/stores/useBugStore.ts
git commit -m "feat(Bug): 状态流转接入单条工作流"
```

---

### Task 5: Add Strict AI Estimation Policy

**Files:**
- Create: `apps/web/lib/ai-estimation-policy.ts`
- Create: `apps/web/lib/ai-estimation-policy.test.ts`

- [ ] **Step 1: Write failing estimate policy tests**

Create `apps/web/lib/ai-estimation-policy.test.ts`:

```ts
import test from 'node:test';
import assert from 'node:assert/strict';

import { clampDevEstimateHours, clampTestCaseEstimateHours, getDefaultTestCaseEstimateHours } from './ai-estimation-policy';

test('clamps simple frontend interaction to AI-assisted range', () => {
  assert.equal(clampDevEstimateHours('frontend_interaction', 5), 1);
  assert.equal(clampDevEstimateHours('frontend_interaction', 0.1), 0.5);
});

test('keeps backend API within strict range', () => {
  assert.equal(clampDevEstimateHours('backend_api', 0.25), 0.75);
  assert.equal(clampDevEstimateHours('backend_api', 2), 1.5);
});

test('defaults and clamps test case estimates', () => {
  assert.equal(getDefaultTestCaseEstimateHours('test_functional'), 0.5);
  assert.equal(clampTestCaseEstimateHours('test_functional', 2), 0.5);
  assert.equal(clampTestCaseEstimateHours('test_api', 0.25), 0.5);
  assert.equal(clampTestCaseEstimateHours('test_exception', 2), 1);
});
```

- [ ] **Step 2: Run test and confirm failure**

Run:

```bash
pnpm --filter web test
```

Expected: FAIL because `ai-estimation-policy.ts` does not exist.

- [ ] **Step 3: Implement estimate policy**

Create `apps/web/lib/ai-estimation-policy.ts`:

```ts
import type { AgentTaskCategoryCode } from '@ftb/shared';

type EstimateRange = { min: number; max: number; fallback: number };

const DEV_ESTIMATE_RANGES: Record<string, EstimateRange> = {
  frontend_development: { min: 0.5, max: 1.5, fallback: 1 },
  frontend_interaction: { min: 0.5, max: 1, fallback: 0.5 },
  backend_development: { min: 1, max: 3, fallback: 2 },
  backend_api: { min: 0.75, max: 1.5, fallback: 1 },
  database_schema: { min: 0.5, max: 0.5, fallback: 0.5 },
  api_integration: { min: 0.75, max: 1.5, fallback: 1 },
  data_processing: { min: 1, max: 2, fallback: 1.5 },
  implementation_support: { min: 0.5, max: 1.5, fallback: 1 },
  documentation: { min: 0.25, max: 0.5, fallback: 0.5 },
};

const TEST_ESTIMATE_RANGES: Record<string, EstimateRange> = {
  test_functional: { min: 0.25, max: 0.5, fallback: 0.5 },
  test_api: { min: 0.5, max: 1, fallback: 0.5 },
  test_exception: { min: 0.5, max: 1, fallback: 0.5 },
  test_compatibility: { min: 0.5, max: 1, fallback: 1 },
};

function roundQuarterHour(hours: number): number {
  return Math.round(hours * 4) / 4;
}

function clampToRange(raw: number | undefined, range: EstimateRange): number {
  const base = typeof raw === 'number' && Number.isFinite(raw) && raw > 0 ? raw : range.fallback;
  return roundQuarterHour(Math.min(range.max, Math.max(range.min, base)));
}

export function clampDevEstimateHours(code: AgentTaskCategoryCode | string | undefined, raw: number | undefined): number {
  return clampToRange(raw, DEV_ESTIMATE_RANGES[code ?? ''] ?? { min: 0.5, max: 2, fallback: 1 });
}

export function getDefaultTestCaseEstimateHours(code: AgentTaskCategoryCode | string | undefined): number {
  return (TEST_ESTIMATE_RANGES[code ?? ''] ?? TEST_ESTIMATE_RANGES.test_functional).fallback;
}

export function clampTestCaseEstimateHours(code: AgentTaskCategoryCode | string | undefined, raw: number | undefined): number {
  return clampToRange(raw, TEST_ESTIMATE_RANGES[code ?? ''] ?? TEST_ESTIMATE_RANGES.test_functional);
}
```

- [ ] **Step 4: Run tests**

Run:

```bash
pnpm --filter web test
```

Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add apps/web/lib/ai-estimation-policy.ts apps/web/lib/ai-estimation-policy.test.ts
git commit -m "feat(AI): 增加严格估时策略"
```

---

### Task 6: Update TestCase UI for Estimates and Single-Item Flow

**Files:**
- Modify: `apps/web/components/test-case/TestCaseCreateModal.tsx`
- Modify: `apps/web/components/test-case/TestCaseTab.tsx`
- Modify: `apps/web/components/test-case/TestCaseRow.tsx`
- Modify: `apps/web/components/test-case/TestCaseDetailDrawer.tsx`

- [ ] **Step 1: Add estimate input to TestCase create modal**

Modify `apps/web/components/test-case/TestCaseCreateModal.tsx` imports:

```ts
import { clampTestCaseEstimateHours, getDefaultTestCaseEstimateHours } from '@/lib/ai-estimation-policy';
import { findCategoryByCode } from '@/lib/task-category';
```

Add state after `categoryId`:

```ts
const selectedCategory = useMemo(
  () => categories.find((category) => category.id === categoryId),
  [categories, categoryId],
);
const [estimateHours, setEstimateHours] = useState(0.5);
```

Add a category change handler:

```ts
const handleCategoryChange = (nextCategoryId: string) => {
  setCategoryId(nextCategoryId);
  const category = categories.find((c) => c.id === nextCategoryId);
  setEstimateHours(getDefaultTestCaseEstimateHours(category?.code));
};
```

Use it in the task type select:

```tsx
<select value={categoryId} onChange={(e) => handleCategoryChange(e.target.value)} className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none">
```

Add estimate input under task type:

```tsx
<div>
  <label className="block text-[12px] text-[var(--ink-soft)] mb-1">预估耗时 *</label>
  <input
    type="number"
    min="0.25"
    max="2"
    step="0.25"
    value={estimateHours}
    onChange={(e) => setEstimateHours(Number(e.target.value))}
    onBlur={() => setEstimateHours(clampTestCaseEstimateHours(selectedCategory?.code, estimateHours))}
    className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none"
  />
</div>
```

Pass estimate to store:

```ts
estimateHours: clampTestCaseEstimateHours(selectedCategory?.code, estimateHours),
```

- [ ] **Step 2: Update TestCase tab stats**

Modify imports in `apps/web/components/test-case/TestCaseTab.tsx`:

```ts
import { aggregateTestCaseHours, calcTestProgress, TEST_CASE_STATUS_LABEL, testCaseIntervals } from '@/lib/test-case';
```

Add:

```ts
const { estimate: tcEstimateHours, actual: tcActualHours } = aggregateTestCaseHours(versionCases);
```

Replace the stat text:

```tsx
<span className="text-[11px] text-[var(--ink-muted)] shrink-0">
  {stats.total}例 · 通过{stats.passed} · 不通过{stats.failed} · 预 {formatWorkHours(tcEstimateHours)} / 实 {formatWorkHours(tcActualHours)}
</span>
```

Keep calendar/person metrics:

```tsx
{tcManhours > 0 && (
  <span className="text-[11px] text-[var(--ink-muted)] shrink-0">
    日历 {formatWorkHours(tcCalendarHours)} / 人力 {formatWorkHours(tcManhours)}
  </span>
)}
```

- [ ] **Step 3: Update TestCase row hours display**

Modify `apps/web/components/test-case/TestCaseRow.tsx`:

```ts
import { getTestCaseActualHours, getTestCaseEstimateHours } from '@/lib/test-case';
```

Inside component:

```ts
const estimateHours = getTestCaseEstimateHours(testCase);
const actualHours = getTestCaseActualHours(testCase);
```

Replace hours render:

```tsx
<span className="text-[11px] text-[var(--ink-muted)] tabular-nums w-32 text-right shrink-0 whitespace-nowrap">
  {actualHours > 0 ? `${formatWorkHours(actualHours)} / ${formatWorkHours(estimateHours)}` : `预 ${formatWorkHours(estimateHours)}`}
</span>
```

- [ ] **Step 4: Update TestCase drawer time fields**

Modify `apps/web/components/test-case/TestCaseDetailDrawer.tsx` imports:

```ts
import { getTestCaseActualHours, getTestCaseEstimateHours } from '@/lib/test-case';
import { formatWorkHours } from '@/lib/work-hours';
```

Remove:

```ts
import { calcActualHoursByDates } from '@/lib/dev-task';
```

Add:

```ts
const estimateHours = getTestCaseEstimateHours(tc);
const actualHours = getTestCaseActualHours(tc);
```

Replace the testing duration line:

```tsx
<div><span className="text-[var(--ink-muted)]">预估耗时：</span><span className="text-[var(--ink)] font-medium">{formatWorkHours(estimateHours)}</span></div>
{tc.startedAt && <div><span className="text-[var(--ink-muted)]">实际耗时：</span><span className="text-[var(--ink)] font-medium">{formatWorkHours(actualHours)}</span></div>}
```

- [ ] **Step 5: Verify no batch status actions exist**

Run:

```bash
rg -n "batch|全部通过|全部不通过|全部提测|forEach\\(.*changeStatus|changeStatus\\(.*forEach" apps/web/components apps/web/stores
```

Expected: any result is either batch delete or plain display copy. No batch status transition remains.

- [ ] **Step 6: Run verification**

Run:

```bash
pnpm --filter web type-check
```

Expected: PASS.

- [ ] **Step 7: Commit**

```bash
git add apps/web/components/test-case/TestCaseCreateModal.tsx apps/web/components/test-case/TestCaseTab.tsx apps/web/components/test-case/TestCaseRow.tsx apps/web/components/test-case/TestCaseDetailDrawer.tsx
git commit -m "feat(测试用例): 展示预估和实际耗时"
```

---

### Task 7: Update AI Contract and Adoption Estimates

**Files:**
- Modify: `packages/shared/src/agent.ts`
- Modify: `apps/server/src/modules/ai/prompts/decompose.ts`
- Modify: `apps/server/src/modules/ai/ai.service.spec.ts`
- Modify: `apps/web/components/version/DecomposeReportModal.tsx`

- [ ] **Step 1: Update shared TestCase draft type**

Modify `packages/shared/src/agent.ts`:

```ts
export interface AgentTestCaseDraft {
  title: string;
  description: string;
  categoryCode: AgentTaskCategoryCode;
  priority: 'P0' | 'P1' | 'P2' | 'P3';
  estimateHours: number;
  references: AgentReference[];
}
```

- [ ] **Step 2: Tighten prompt estimate policy**

Modify `apps/server/src/modules/ai/prompts/decompose.ts` section `5. 工时估算（小时）` to:

```ts
5. 工时估算（小时，按团队使用 AI 辅助研发/测试估算，必须偏严格）
   - 简单前端字段、文案、展示调整: 0.25-0.5h
   - 简单前端交互，如拖拽排序 UI、开关、筛选项: 0.5-1h
   - 拖拽排序并需要持久化接口: 1-1.5h
   - 简单 CRUD 接口: 0.75-1.5h
   - 数据库字段/索引调整: 0.5h
   - 中等业务规则变更: 1.5-3h
   - 简单功能测试用例执行: 0.25-0.5h
   - API/异常/兼容性测试用例执行: 0.5-1h
   - 只有跨端同步、复杂权限、历史数据迁移、强一致性、复杂兼容性时，才允许超过上述区间
```

Add TestCase schema property:

```ts
estimateHours: { type: 'number' },
```

Update TestCase required array:

```ts
required: ['title', 'description', 'categoryCode', 'priority', 'estimateHours', 'references'],
```

- [ ] **Step 3: Update server schema test**

Modify `apps/server/src/modules/ai/ai.service.spec.ts`:

```ts
it('requires estimateHours for test case drafts', () => {
  const testCaseRequired = (DECOMPOSE_TOOL_INPUT_SCHEMA.properties.testCaseDrafts as any).items.required;
  assert(testCaseRequired.includes('estimateHours'));
});
```

Use the existing assertion style in that file. If the file uses Jest `expect`, write:

```ts
expect(testCaseRequired).toContain('estimateHours');
```

- [ ] **Step 4: Clamp estimates when adopting AI drafts**

Modify `apps/web/components/version/DecomposeReportModal.tsx` imports:

```ts
import { addWorkHours } from '@/lib/work-hours';
import { clampDevEstimateHours, clampTestCaseEstimateHours } from '@/lib/ai-estimation-policy';
```

Replace AI dev estimate logic:

```ts
const estimateHours = clampDevEstimateHours(draft.categoryCode, draft.estimateHours);
const startISO = new Date().toISOString();
const endISO = addWorkHours(startISO, estimateHours);
```

Include in `createTask`:

```ts
estimateHours,
expectedStartAt: startISO,
expectedEndAt: endISO,
actualStartAt: undefined,
actualEndAt: undefined,
status: 'todo',
```

For test cases:

```ts
const estimateHours = clampTestCaseEstimateHours(draft.categoryCode, draft.estimateHours);
```

Include in `createTestCase`:

```ts
estimateHours,
```

Show estimate in TestCase draft preview:

```tsx
<span className="text-[10px] text-[var(--ink-muted)]">{clampTestCaseEstimateHours(d.categoryCode, d.estimateHours)}h</span>
```

- [ ] **Step 5: Run verification**

Run:

```bash
pnpm --filter server test
pnpm --filter web test
pnpm --filter web type-check
```

Expected: all pass.

- [ ] **Step 6: Commit**

```bash
git add packages/shared/src/agent.ts apps/server/src/modules/ai/prompts/decompose.ts apps/server/src/modules/ai/ai.service.spec.ts apps/web/components/version/DecomposeReportModal.tsx
git commit -m "feat(AI): 拆解用例增加严格估时"
```

---

### Task 8: Final Verification and Manual Smoke Check

**Files:**
- No source edits unless verification finds focused issues.

- [ ] **Step 1: Run focused automated verification**

Run:

```bash
pnpm --filter web test
pnpm --filter web type-check
pnpm --filter server test
pnpm --filter server type-check
```

Expected: all pass.

- [ ] **Step 2: Confirm no auto-start code path remains**

Run:

```bash
rg -n "findTasksToAutoStart|deriveActualStartAt|status: 'in_progress'.*actualStartAt|autoStart|自动切换扫描" apps/web
```

Expected: no active DevTask auto-start usage remains. A historical comment is acceptable only if it is not imported or called.

- [ ] **Step 3: Confirm no batch status mutation remains**

Run:

```bash
rg -n "全部提测|全部通过|全部不通过|batch.*status|forEach\\(.*changeStatus|changeStatus\\(.*forEach" apps/web
```

Expected: no batch status action remains. Batch delete may remain if found under delete handlers.

- [ ] **Step 4: Browser smoke check**

If the dev server is already running, open `http://localhost:3000/versions` and verify on a version detail page:

```text
1. AI adopted DevTask stays 待开发 and actual start/end are empty.
2. Clicking a single DevTask to 开发中 writes actual start only for that task.
3. Clicking a single DevTask to 已提测 writes actual end only for that task.
4. New TestCase starts 待测试, has estimateHours, and shows estimate in row/drawer.
5. Clicking a single TestCase to 测试中 writes startedAt only for that case.
6. Clicking passed/failed/blocked writes completedAt only for that case.
7. TestCase top progress shows weighted completion plus pre/actual hours.
8. Bug drawer still only changes the currently opened Bug.
```

- [ ] **Step 5: Inspect final diff**

Run:

```bash
git status --short
git diff --stat
```

Expected: only workflow/effort/AI estimate files and related UI/store files changed in this implementation series. Existing unrelated dirty files must not be reverted.

- [ ] **Step 6: Commit verification fixes if needed**

Only if Step 1-5 reveal small focused issues, fix them, rerun the failed command, then commit:

```bash
git add <focused-files>
git commit -m "fix(工作流): 修正单条流转验证问题"
```

Do not create an empty commit.

---

## Self-Review

- Spec coverage: DevTask starts as `todo`, TestCase starts as `pending`, actual timestamps are only written by single-item workflow transitions, TestCase estimates and weighted progress are added, Bug status flow is centralized, AI estimates are stricter and clamped on adoption.
- Placeholder scan: passed. Each task includes concrete files, code, commands, and expected results.
- Type consistency: `estimateHours` is added to DevTask, TestCase, and `AgentTestCaseDraft`; helper names used by stores and UI match the definitions in earlier tasks.
