# Version Module Rules 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:** Unify version-plan completion rules, project-scoped requirement selection, task category semantics, TestCase task types, and AI category output.

**Architecture:** Add small pure helper layers before touching UI: `requirement-selector.ts`, `version-plan-workflow.ts`, and stable `TaskCategory.code` mapping. UI components consume those helpers; stores keep current JSONB/AppData shape with compatibility normalization.

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

---

## File Structure

- Create `apps/web/tsconfig.test.json`: emits web lib tests into `.tmp-test`.
- Create `apps/web/scripts/run-node-tests.mjs`: runs compiled Node test files.
- Modify `apps/web/package.json`: add `test` script.
- Modify `apps/web/lib/task-category.ts`: add `code`, `testing` group, presets, normalization and mapping helpers.
- Create `apps/web/lib/task-category.test.ts`: category normalization and mapping tests.
- Create `apps/web/lib/requirement-selector.ts`: project-scoped adopted requirement selector.
- Create `apps/web/lib/requirement-selector.test.ts`: selector and historical selection tests.
- Create `apps/web/lib/version-plan-workflow.ts`: plan completion state helper.
- Create `apps/web/lib/version-plan-workflow.test.ts`: completion rule tests.
- Modify `apps/web/lib/test-case.ts`: add `categoryId`.
- Modify `apps/web/stores/useTaskCategoryStore.ts`: normalize fetched categories.
- Modify `apps/web/stores/useTestCaseStore.ts`: backfill missing `categoryId`.
- Modify `apps/web/components/test-case/TestCaseCreateModal.tsx`: required task type field.
- Modify `apps/web/components/test-case/TestCaseTab.tsx`: category map and row prop.
- Modify `apps/web/components/test-case/TestCaseRow.tsx`: show category chip.
- Modify `apps/web/components/test-case/TestCaseDetailDrawer.tsx`: show category.
- Modify `apps/web/components/version/PlanTab.tsx`: use plan workflow and requirement selector UI.
- Modify `apps/web/components/version/PlanDetailDrawer.tsx`: use plan workflow for completion.
- Modify `apps/web/app/versions/[id]/page.tsx`: pass project-scoped requirement candidates to plan tabs.
- Modify `apps/web/components/version/DecomposeReportModal.tsx`: map/display `categoryCode`.
- Modify `packages/shared/src/agent.ts`: add `categoryCode` to dev/test drafts.
- Modify `apps/server/src/modules/ai/prompts/decompose.ts`: require `categoryCode` in tool schema and prompt.
- Modify `apps/server/src/modules/ai/ai.service.spec.ts`: assert schema accepts category codes.

---

### Task 1: Add Web Pure-Function Test Harness

**Files:**
- Create: `apps/web/tsconfig.test.json`
- Create: `apps/web/scripts/run-node-tests.mjs`
- Modify: `apps/web/package.json`

- [ ] **Step 1: Add test TypeScript config**

Create `apps/web/tsconfig.test.json`:

```json
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "noEmit": false,
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "target": "ES2022",
    "outDir": ".tmp-test",
    "rootDir": ".",
    "jsx": "react-jsx",
    "types": ["node"]
  },
  "include": ["lib/**/*.ts"],
  "exclude": ["node_modules", ".next", ".tmp-test"]
}
```

- [ ] **Step 2: Add Node test runner script**

Create `apps/web/scripts/run-node-tests.mjs`:

```js
import { readdirSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { spawnSync } from 'node:child_process';

function collectTestFiles(dir) {
  const out = [];
  for (const entry of readdirSync(dir)) {
    const full = join(dir, entry);
    const stat = statSync(full);
    if (stat.isDirectory()) out.push(...collectTestFiles(full));
    if (stat.isFile() && full.endsWith('.test.js')) out.push(full);
  }
  return out;
}

const root = process.argv[2] || '.tmp-test';
const files = collectTestFiles(root);

if (files.length === 0) {
  console.error(`No compiled .test.js files found under ${root}`);
  process.exit(1);
}

const result = spawnSync(process.execPath, ['--test', ...files], { stdio: 'inherit' });
process.exit(result.status ?? 1);
```

- [ ] **Step 3: Add `test` script to web package**

Modify `apps/web/package.json` scripts:

```json
"test": "tsc -p tsconfig.test.json && node scripts/run-node-tests.mjs .tmp-test"
```

- [ ] **Step 4: Run existing web test harness**

Run:

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

Expected: existing `product-overview-persistence.test.ts` compiles and passes.

- [ ] **Step 5: Commit**

```bash
git add apps/web/package.json apps/web/tsconfig.test.json apps/web/scripts/run-node-tests.mjs
git commit -m "test(web): 增加纯函数测试脚本"
```

---

### Task 2: Extend TaskCategory With Stable Codes

**Files:**
- Modify: `apps/web/lib/task-category.ts`
- Modify: `apps/web/stores/useTaskCategoryStore.ts`
- Create: `apps/web/lib/task-category.test.ts`

- [ ] **Step 1: Write category mapping tests**

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

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

import {
  DEFAULT_TEST_CATEGORY_ID,
  PRESET_CATEGORIES,
  findCategoryByCode,
  getDefaultCategoryByGroup,
  normalizeTaskCategories,
  resolveCategoryIdFromCode,
} from './task-category';

test('preset categories include stable codes and testing group', () => {
  assert.ok(PRESET_CATEGORIES.some((c) => c.code === 'test_functional' && c.group === 'testing'));
  assert.ok(PRESET_CATEGORIES.every((c) => c.code.length > 0));
});

test('normalizes legacy categories without code', () => {
  const normalized = normalizeTaskCategories([
    { id: 'cat-1', name: '前端开发', group: 'development', sortOrder: 1, isSystem: true } as any,
  ]);

  assert.equal(normalized[0].code, 'frontend_development');
});

test('resolves category id from stable code', () => {
  const id = resolveCategoryIdFromCode(PRESET_CATEGORIES, 'test_functional', 'testing');
  assert.equal(id, DEFAULT_TEST_CATEGORY_ID);
});

test('falls back to default group category for unknown code', () => {
  const category = getDefaultCategoryByGroup(PRESET_CATEGORIES, 'testing');
  assert.equal(resolveCategoryIdFromCode(PRESET_CATEGORIES, 'unknown_code', 'testing'), category.id);
});

test('finds category by code', () => {
  assert.equal(findCategoryByCode(PRESET_CATEGORIES, 'backend_api')?.name, '后端接口');
});
```

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

Run:

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

Expected: FAIL because helper exports do not exist yet.

- [ ] **Step 3: Update category type and presets**

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

```ts
export type CategoryGroup = 'development' | 'testing' | 'implementation' | 'other';

export const DEFAULT_TEST_CATEGORY_ID = 'cat-test-functional';

export interface TaskCategory {
  id: string;
  code: string;
  name: string;
  group: CategoryGroup;
  color?: string;
  sortOrder: number;
  isSystem: boolean;
}

export const CATEGORY_GROUP_LABEL: Record<CategoryGroup, string> = {
  development: '开发',
  testing: '测试',
  implementation: '实施',
  other: '其他',
};

export const PRESET_CATEGORIES: TaskCategory[] = [
  { id: 'cat-1', code: 'frontend_development', name: '前端开发', group: 'development', color: '#3b82f6', sortOrder: 1, isSystem: true },
  { id: 'cat-2', code: 'backend_development', name: '后端开发', group: 'development', color: '#6366f1', sortOrder: 2, isSystem: true },
  { id: 'cat-backend-api', code: 'backend_api', name: '后端接口', group: 'development', color: '#2563eb', sortOrder: 3, isSystem: true },
  { id: 'cat-frontend-interaction', code: 'frontend_interaction', name: '前端交互', group: 'development', color: '#0ea5e9', sortOrder: 4, isSystem: true },
  { id: 'cat-3', code: 'database_schema', name: '数据库设计', group: 'development', color: '#8b5cf6', sortOrder: 5, isSystem: true },
  { id: 'cat-4', code: 'api_integration', name: '接口联调', group: 'development', color: '#06b6d4', sortOrder: 6, isSystem: true },
  { id: DEFAULT_TEST_CATEGORY_ID, code: 'test_functional', name: '功能测试', group: 'testing', color: '#22c55e', sortOrder: 20, isSystem: true },
  { id: 'cat-test-api', code: 'test_api', name: '接口测试', group: 'testing', color: '#14b8a6', sortOrder: 21, isSystem: true },
  { id: 'cat-test-exception', code: 'test_exception', name: '异常场景测试', group: 'testing', color: '#f97316', sortOrder: 22, isSystem: true },
  { id: 'cat-test-compatibility', code: 'test_compatibility', name: '兼容性测试', group: 'testing', color: '#a855f7', sortOrder: 23, isSystem: true },
  { id: 'cat-5', code: 'data_processing', name: '数据处理', group: 'implementation', color: '#f59e0b', sortOrder: 40, isSystem: true },
  { id: 'cat-6', code: 'implementation_support', name: '实施支持', group: 'implementation', color: '#10b981', sortOrder: 41, isSystem: true },
  { id: 'cat-other-doc', code: 'documentation', name: '文档', group: 'other', color: '#64748b', sortOrder: 60, isSystem: true },
];
```

- [ ] **Step 4: Add normalization helpers**

Add to `apps/web/lib/task-category.ts`:

```ts
const LEGACY_CODE_BY_ID: Record<string, string> = {
  'cat-1': 'frontend_development',
  'cat-2': 'backend_development',
  'cat-3': 'database_schema',
  'cat-4': 'api_integration',
  'cat-5': 'data_processing',
  'cat-6': 'implementation_support',
};

function slugifyCategoryName(name: string): string {
  return name
    .trim()
    .toLowerCase()
    .replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '_')
    .replace(/^_+|_+$/g, '') || `category_${Date.now()}`;
}

export function normalizeTaskCategory(category: any, index = 0): TaskCategory {
  const preset = PRESET_CATEGORIES.find((c) => c.id === category?.id);
  return {
    ...category,
    code: category?.code || LEGACY_CODE_BY_ID[category?.id] || preset?.code || slugifyCategoryName(category?.name || `category_${index}`),
    group: category?.group || 'other',
    sortOrder: typeof category?.sortOrder === 'number' ? category.sortOrder : index + 1,
    isSystem: Boolean(category?.isSystem),
  } as TaskCategory;
}

export function normalizeTaskCategories(categories: any[]): TaskCategory[] {
  const merged = [...(Array.isArray(categories) ? categories : [])];
  for (const preset of PRESET_CATEGORIES) {
    if (!merged.some((c) => c.id === preset.id)) merged.push(preset);
  }
  return merged.map((c, index) => normalizeTaskCategory(c, index));
}

export function findCategoryByCode(categories: TaskCategory[], code?: string): TaskCategory | undefined {
  if (!code) return undefined;
  return categories.find((c) => c.code === code);
}

export function getDefaultCategoryByGroup(categories: TaskCategory[], group: CategoryGroup): TaskCategory {
  return getCategoriesByGroup(categories, group)[0] ?? categories[0] ?? PRESET_CATEGORIES[0];
}

export function resolveCategoryIdFromCode(categories: TaskCategory[], code: string | undefined, fallbackGroup: CategoryGroup): string {
  return findCategoryByCode(categories, code)?.id ?? getDefaultCategoryByGroup(categories, fallbackGroup).id;
}
```

- [ ] **Step 5: Normalize task category store data**

Modify `apps/web/stores/useTaskCategoryStore.ts`:

```ts
import { PRESET_CATEGORIES, normalizeTaskCategories } from '@/lib/task-category';
```

In `fetchCategories`:

```ts
const cached = await loadStored();
if (cached) set({ categories: normalizeTaskCategories(cached) });
```

In `addCategory`, include `code`:

```ts
code: name.trim().toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '_'),
```

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

Run:

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

Expected: both pass.

- [ ] **Step 7: Commit**

```bash
git add apps/web/lib/task-category.ts apps/web/lib/task-category.test.ts apps/web/stores/useTaskCategoryStore.ts
git commit -m "feat(任务类型): 增加稳定分类语义码"
```

---

### Task 3: Add Project-Scoped Requirement Selector

**Files:**
- Create: `apps/web/lib/requirement-selector.ts`
- Create: `apps/web/lib/requirement-selector.test.ts`

- [ ] **Step 1: Write selector tests**

Create `apps/web/lib/requirement-selector.test.ts`:

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

import { getProjectAdoptedRequirementCandidates, mergeSelectedRequirementOptions } from './requirement-selector';
import type { Requirement } from './requirement';

const base = {
  description: '',
  productId: 'product-1',
  sourceType: 'internal',
  sourceTarget: '',
  platforms: [],
  typeId: '',
  priority: 'P2',
  effort: 'M',
  creator: 'tester',
  createdAt: '2026-06-25',
} as const;

function req(id: string, projectId: string, status: Requirement['status']): Requirement {
  return {
    ...base,
    id,
    code: id.toUpperCase(),
    title: `Requirement ${id}`,
    projectId,
    status,
  } as Requirement;
}

test('returns only adopted requirements from current project', () => {
  const result = getProjectAdoptedRequirementCandidates([
    req('r1', 'project-1', 'adopted'),
    req('r2', 'project-1', 'pending_review'),
    req('r3', 'project-2', 'adopted'),
  ], 'project-1');

  assert.deepEqual(result.map((r) => r.id), ['r1']);
});

test('keeps historical selected requirements as non-candidate options', () => {
  const options = mergeSelectedRequirementOptions(
    [req('r1', 'project-1', 'adopted')],
    [req('r2', 'project-1', 'developing')],
    ['r1', 'r2'],
  );

  assert.equal(options.find((o) => o.id === 'r2')?.isHistorical, true);
});
```

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

Run:

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

Expected: FAIL because `requirement-selector.ts` does not exist.

- [ ] **Step 3: Implement selector**

Create `apps/web/lib/requirement-selector.ts`:

```ts
import type { Requirement } from './requirement';

export interface RequirementOption {
  id: string;
  code: string;
  title: string;
  productOwner?: string;
  status: Requirement['status'];
  isHistorical?: boolean;
}

export function toRequirementOption(requirement: Requirement, isHistorical = false): RequirementOption {
  return {
    id: requirement.id,
    code: requirement.code,
    title: requirement.title,
    productOwner: requirement.productOwner,
    status: requirement.status,
    isHistorical,
  };
}

export function getProjectAdoptedRequirementCandidates(requirements: Requirement[], projectId: string): Requirement[] {
  return requirements
    .filter((r) => r.projectId === projectId && r.status === 'adopted')
    .sort((a, b) => a.code.localeCompare(b.code, 'zh-CN'));
}

export function mergeSelectedRequirementOptions(
  candidates: Requirement[],
  allRequirements: Requirement[],
  selectedIds: string[] = [],
): RequirementOption[] {
  const candidateOptions = candidates.map((r) => toRequirementOption(r));
  const candidateIds = new Set(candidateOptions.map((r) => r.id));
  const historical = selectedIds
    .filter((id) => !candidateIds.has(id))
    .map((id) => allRequirements.find((r) => r.id === id))
    .filter((r): r is Requirement => Boolean(r))
    .map((r) => toRequirementOption(r, true));
  return [...candidateOptions, ...historical];
}
```

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

Run:

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

Expected: both pass.

- [ ] **Step 5: Commit**

```bash
git add apps/web/lib/requirement-selector.ts apps/web/lib/requirement-selector.test.ts
git commit -m "feat(需求): 统一项目已采纳需求选择器"
```

---

### Task 4: Add VersionPlan Workflow Engine

**Files:**
- Create: `apps/web/lib/version-plan-workflow.ts`
- Create: `apps/web/lib/version-plan-workflow.test.ts`
- Modify: `apps/web/stores/useVersionPlanStore.ts`

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

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

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

import { getPlanCompletionState, hasPlanResult } from './version-plan-workflow';
import type { VersionPlan } from './version-plan';

function plan(patch: Partial<VersionPlan>): VersionPlan {
  return {
    id: 'plan-1',
    versionId: 'version-1',
    type: 'product',
    title: '产品方案',
    owner: 'PM',
    startTime: '2026-06-25T09:00',
    endTime: '2026-06-25T18:00',
    status: 'in_progress',
    tasks: [{ id: 'task-1', title: '梳理方案', status: 'pending' }],
    linkedRequirementIds: ['r1'],
    completedRequirementIds: [],
    createdAt: '2026-06-25',
    addedBy: 'PM',
    ...patch,
  };
}

test('does not allow result submission when subtasks are incomplete', () => {
  const state = getPlanCompletionState(plan({}));
  assert.equal(state.canSubmitResult, false);
  assert.ok(state.missingReasons.includes('子任务未全部完成'));
});

test('requires product requirement coverage when linked requirements exist', () => {
  const state = getPlanCompletionState(plan({
    tasks: [{ id: 'task-1', title: '梳理方案', status: 'completed' }],
  }));
  assert.equal(state.canSubmitResult, false);
  assert.ok(state.missingReasons.includes('关联需求未全部覆盖'));
});

test('allows result submission after checklist and coverage are complete', () => {
  const state = getPlanCompletionState(plan({
    tasks: [{ id: 'task-1', title: '梳理方案', status: 'completed' }],
    completedRequirementIds: ['r1'],
  }));
  assert.equal(state.canSubmitResult, true);
  assert.equal(state.canComplete, false);
});

test('allows completion only after result exists', () => {
  const state = getPlanCompletionState(plan({
    tasks: [{ id: 'task-1', title: '梳理方案', status: 'completed' }],
    completedRequirementIds: ['r1'],
    resultType: 'link',
    resultTitle: '原型',
    resultUrl: 'https://example.com/prototype',
  }));
  assert.equal(state.canComplete, true);
});

test('research requires tasks and result but not requirement coverage', () => {
  const state = getPlanCompletionState(plan({
    type: 'research',
    tasks: [{ id: 'task-1', title: '调研', status: 'completed' }],
    linkedRequirementIds: ['r1'],
    completedRequirementIds: [],
    resultType: 'file',
    resultTitle: '调研报告',
    resultFileName: 'report.pdf',
    resultFileData: 'data:application/pdf;base64,abc',
  }));
  assert.equal(state.canComplete, true);
});

test('detects link and file result payloads', () => {
  assert.equal(hasPlanResult({ resultType: 'link', resultTitle: '原型', resultUrl: 'https://example.com' }), true);
  assert.equal(hasPlanResult({ resultType: 'file', resultTitle: '文件', resultFileName: 'a.pdf', resultFileData: 'data:pdf' }), true);
  assert.equal(hasPlanResult({ resultType: 'link', resultTitle: '原型' }), false);
});
```

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

Run:

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

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

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

Create `apps/web/lib/version-plan-workflow.ts`:

```ts
import type { VersionPlan } from './version-plan';

export interface PlanResultPayload {
  resultType?: 'link' | 'file';
  resultTitle?: string;
  resultUrl?: string;
  resultFileName?: string;
  resultFileData?: string;
}

export interface PlanCompletionState {
  checklistTotal: number;
  checklistCompleted: number;
  requirementTotal: number;
  requirementCompleted: number;
  hasResult: boolean;
  canSubmitResult: boolean;
  canComplete: boolean;
  missingReasons: string[];
}

export function hasPlanResult(plan: PlanResultPayload): boolean {
  const hasTitle = Boolean(plan.resultTitle?.trim());
  if (!hasTitle || !plan.resultType) return false;
  if (plan.resultType === 'link') return Boolean(plan.resultUrl?.trim());
  return Boolean(plan.resultFileData || plan.resultFileName);
}

function requiresRequirementCoverage(plan: VersionPlan): boolean {
  return plan.type === 'product' || plan.type === 'ui';
}

export function getPlanCompletionState(plan: VersionPlan): PlanCompletionState {
  const tasks = plan.tasks ?? [];
  const checklistTotal = tasks.length;
  const checklistCompleted = tasks.filter((t) => t.status === 'completed').length;

  const linked = plan.linkedRequirementIds ?? [];
  const completed = new Set(plan.completedRequirementIds ?? []);
  const requirementTotal = linked.length;
  const requirementCompleted = linked.filter((id) => completed.has(id)).length;

  const missingReasons: string[] = [];
  if (checklistTotal === 0) missingReasons.push('缺少子任务');
  if (checklistTotal > 0 && checklistCompleted < checklistTotal) missingReasons.push('子任务未全部完成');
  if (requiresRequirementCoverage(plan) && requirementTotal > 0 && requirementCompleted < requirementTotal) {
    missingReasons.push('关联需求未全部覆盖');
  }

  const canSubmitResult = missingReasons.length === 0;
  const hasResult = hasPlanResult(plan);
  if (canSubmitResult && !hasResult) missingReasons.push('尚未提交成果');

  return {
    checklistTotal,
    checklistCompleted,
    requirementTotal,
    requirementCompleted,
    hasResult,
    canSubmitResult,
    canComplete: canSubmitResult && hasResult,
    missingReasons,
  };
}

export function canTogglePlanChecklist(plan: VersionPlan, now: Date = new Date()): boolean {
  return plan.status === 'in_progress' || (plan.status === 'pending' && Boolean(plan.startTime) && new Date(plan.startTime) <= now);
}

export function canEditPlanRequirementCoverage(plan: VersionPlan, now: Date = new Date()): boolean {
  return canTogglePlanChecklist(plan, now);
}
```

- [ ] **Step 4: Guard store completion**

Modify `apps/web/stores/useVersionPlanStore.ts`:

```ts
import { getPlanCompletionState } from '@/lib/version-plan-workflow';
```

Change `completePlan` type:

```ts
completePlan: (id: string, result: { resultType: 'link' | 'file'; resultTitle: string; resultUrl?: string; resultFileName?: string; resultFileData?: string }) => { ok: boolean; message?: string };
```

Update implementation:

```ts
completePlan: (id, result) => {
  let response: { ok: boolean; message?: string } = { ok: false, message: '计划不存在' };
  const plans = get().plans.map((p) => {
    if (p.id !== id) return p;
    const next = { ...p, ...result, status: 'completed' as const, completedAt: new Date().toISOString() };
    const state = getPlanCompletionState(next);
    if (!state.canComplete) {
      response = { ok: false, message: state.missingReasons.join('、') || '计划未满足完成条件' };
      return p;
    }
    response = { ok: true };
    return next;
  });
  set({ plans });
  saveStored(plans);
  return response;
},
```

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

Run:

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

Expected: both pass.

- [ ] **Step 6: Commit**

```bash
git add apps/web/lib/version-plan-workflow.ts apps/web/lib/version-plan-workflow.test.ts apps/web/stores/useVersionPlanStore.ts
git commit -m "feat(版本计划): 增加完成规则引擎"
```

---

### Task 5: Add Task Type to TestCase

**Files:**
- Modify: `apps/web/lib/test-case.ts`
- Modify: `apps/web/stores/useTestCaseStore.ts`
- 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 `categoryId` to TestCase type**

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

```ts
export interface TestCase {
  id: string;
  caseNo: string;
  versionId: string;
  requirementId?: string;
  title: string;
  description?: string;
  categoryId: string;
  priority: Priority;
  assigneeId?: string;
  status: TestCaseStatus;
  // existing fields stay unchanged
}
```

- [ ] **Step 2: Backfill old TestCase data**

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

```ts
import { DEFAULT_TEST_CATEGORY_ID } from '@/lib/task-category';
```

In `fetchTestCases`:

```ts
if (cached) {
  set({
    testCases: cached.map((c) => ({
      ...c,
      categoryId: c.categoryId || DEFAULT_TEST_CATEGORY_ID,
    })),
  });
}
```

- [ ] **Step 3: Add task type selector to TestCase create modal**

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

```ts
import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
import { getDefaultCategoryByGroup, getCategoriesByGroup } from '@/lib/task-category';
```

Add state:

```ts
const { categories } = useTaskCategoryStore();
const testCategories = useMemo(() => getCategoriesByGroup(categories, 'testing'), [categories]);
const [categoryId, setCategoryId] = useState(getDefaultCategoryByGroup(categories, 'testing').id);
```

Update submit guard:

```ts
const canSubmit = title.trim() && categoryId;
```

Pass `categoryId` into `createTestCase`.

Add field near priority:

```tsx
<div>
  <label className="block text-[12px] text-[var(--ink-soft)] mb-1">任务类型 *</label>
  <select value={categoryId} onChange={(e) => setCategoryId(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">
    {testCategories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
  </select>
</div>
```

- [ ] **Step 4: Show category in TestCase list**

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

```ts
import type { TaskCategory } from '@/lib/task-category';
import { CategoryChip } from '@/components/dev-task/CategoryChip';

interface Props {
  testCase: TestCase;
  category?: TaskCategory;
  bugCount: number;
  onClick?: () => void;
}
```

Render:

```tsx
<CategoryChip category={category} />
```

Modify `apps/web/components/test-case/TestCaseTab.tsx` to fetch categories and pass `categoryMap.get(tc.categoryId)`.

- [ ] **Step 5: Show category in TestCase drawer**

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

```ts
import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
import { CategoryChip } from '@/components/dev-task/CategoryChip';
```

Find category:

```ts
const { categories } = useTaskCategoryStore();
const category = categories.find((c) => c.id === tc.categoryId);
```

Render in basic info:

```tsx
<div className="col-span-2 flex items-center gap-2">
  <span className="text-[var(--ink-muted)]">任务类型：</span>
  <CategoryChip category={category} />
</div>
```

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

Run:

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

Expected: PASS.

- [ ] **Step 7: Commit**

```bash
git add apps/web/lib/test-case.ts apps/web/stores/useTestCaseStore.ts 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 6: Wire Requirement Selector and Plan Workflow Into Version UI

**Files:**
- Modify: `apps/web/app/versions/[id]/page.tsx`
- Modify: `apps/web/components/version/PlanTab.tsx`
- Modify: `apps/web/components/version/PlanDetailDrawer.tsx`

- [ ] **Step 1: Pass project-scoped adopted requirements to PlanTab**

Modify `apps/web/app/versions/[id]/page.tsx`:

```ts
import { getProjectAdoptedRequirementCandidates } from '@/lib/requirement-selector';
```

In the plan tab render block:

```ts
const projectAdoptedReqs = getProjectAdoptedRequirementCandidates(requirements, version.projectId);
const linkedReqs = projectAdoptedReqs.map((r) => ({
  id: r.id,
  title: r.title,
  code: r.code,
  productOwner: r.productOwner,
}));
```

Pass to every `PlanTab`:

```tsx
linkedRequirements={linkedReqs}
```

- [ ] **Step 2: Make requirement selection available for all plan types**

Modify `PlanFormModal` in `apps/web/components/version/PlanTab.tsx`:

```ts
const showReqSelect = true;
```

Remove the old `planType === 'product' || planType === 'ui'` guard.

- [ ] **Step 3: Add select-all controls**

Inside the linked requirement selection block, add:

```tsx
<div className="mb-2 flex items-center justify-between">
  <span className="text-[11px] text-[var(--ink-muted)]">
    已选 {selectedReqs.size} / {linkedRequirements.length}
  </span>
  <div className="flex gap-2">
    <button type="button" onClick={() => setSelectedReqs(new Set(linkedRequirements.map((r) => r.id)))} className="text-[11px] text-[var(--accent)] hover:underline">全选</button>
    <button type="button" onClick={() => setSelectedReqs(new Set())} className="text-[11px] text-[var(--ink-muted)] hover:underline">清空</button>
  </div>
</div>
```

- [ ] **Step 4: Make tasks required for research/product/ui**

In `PlanFormModal`, change validation:

```ts
if (tasks.length === 0) return;
```

Remove the `planType === 'research'` condition around the task checklist UI so every plan type can add subtasks.

- [ ] **Step 5: Use workflow state in PlanTab**

Import:

```ts
import { getPlanCompletionState, canTogglePlanChecklist, canEditPlanRequirementCoverage } from '@/lib/version-plan-workflow';
```

Inside each plan render:

```ts
const completionState = getPlanCompletionState(plan);
const canToggle = canTogglePlanChecklist(plan);
const canEditCoverage = canEditPlanRequirementCoverage(plan);
```

Use `canToggle` for task checkbox disabled state.

Use `canEditCoverage` for requirement checkbox disabled state.

Remove this direct-complete button block entirely:

```tsx
{plan.status !== 'completed' && plan.status !== 'pending' && (
  <button
    type="button"
    onClick={() => setCompletingPlan(plan)}
    className="rounded-md p-1 text-emerald-600 hover:bg-emerald-50"
    title="完成"
  >
    <Check className="h-3.5 w-3.5" />
  </button>
)}
```

Replace green submit prompt with:

```tsx
{plan.status !== 'completed' && completionState.canSubmitResult && (
  <div className="mt-2 rounded-lg bg-green-50 border border-green-200 px-3 py-2 flex items-center justify-between">
    <span className="text-[12px] text-green-700">已满足提交成果条件</span>
    <button onClick={() => setCompletingPlan(plan)} className="text-[11px] font-medium text-green-700 hover:text-green-900 underline">提交成果</button>
  </div>
)}
```

Show missing reasons when in progress:

```tsx
{plan.status === 'in_progress' && !completionState.canSubmitResult && (
  <p className="mt-2 text-[11px] text-[var(--ink-muted)]">
    还不能提交成果：{completionState.missingReasons.join('、')}
  </p>
)}
```

- [ ] **Step 6: Use workflow state in PlanDetailDrawer**

Modify `apps/web/components/version/PlanDetailDrawer.tsx` similarly:

```ts
const completionState = getPlanCompletionState(plan);
```

Disable “提交完成” button unless `completionState.canSubmitResult`.

Show missing reasons under the disabled button.

When submitting result:

```ts
const res = completePlan(plan.id, { resultType, resultTitle: title, resultUrl: url, resultFileName: fileName || undefined, resultFileData: resultType === 'file' ? fileData : undefined });
if (!res.ok) {
  alert(res.message || '计划未满足完成条件');
  return;
}
```

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

Run:

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

Expected: both pass.

- [ ] **Step 8: Commit**

```bash
git add apps/web/app/versions/[id]/page.tsx apps/web/components/version/PlanTab.tsx apps/web/components/version/PlanDetailDrawer.tsx
git commit -m "feat(版本计划): 接入完成规则和需求全选"
```

---

### Task 7: Upgrade AI Decompose Category Contract

**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 agent types**

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

```ts
export type AgentTaskCategoryCode =
  | 'frontend_development'
  | 'frontend_interaction'
  | 'backend_development'
  | 'backend_api'
  | 'database_schema'
  | 'api_integration'
  | 'test_functional'
  | 'test_api'
  | 'test_exception'
  | 'test_compatibility'
  | 'data_processing'
  | 'implementation_support'
  | 'documentation';

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

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

Keep `AgentRole` only if other code still imports it; remove usage from dev draft after all references are updated.

- [ ] **Step 2: Update server prompt and schema**

Modify `apps/server/src/modules/ai/prompts/decompose.ts`:

- Replace role instruction with category instruction:

```ts
4. 任务类型
   - 每条开发任务和测试用例都必须输出 categoryCode
   - 开发任务优先使用 frontend_development / frontend_interaction / backend_development / backend_api / database_schema / api_integration
   - 测试用例优先使用 test_functional / test_api / test_exception / test_compatibility
   - 不输出数据库 categoryId
```

- In `devTaskDrafts.items.properties`, replace `role` with:

```ts
categoryCode: {
  type: 'string',
  enum: [
    'frontend_development',
    'frontend_interaction',
    'backend_development',
    'backend_api',
    'database_schema',
    'api_integration',
    'data_processing',
    'implementation_support',
    'documentation',
  ],
},
```

- In `testCaseDrafts.items.properties`, add:

```ts
categoryCode: {
  type: 'string',
  enum: ['test_functional', 'test_api', 'test_exception', 'test_compatibility'],
},
```

- Update required arrays:

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

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

Modify `apps/server/src/modules/ai/ai.service.spec.ts` or add a new test:

```ts
import { DECOMPOSE_TOOL_INPUT_SCHEMA } from './prompts/decompose';

it('requires categoryCode for dev task and test case drafts', () => {
  const devRequired = (DECOMPOSE_TOOL_INPUT_SCHEMA.properties.devTaskDrafts.items as any).required;
  const tcRequired = (DECOMPOSE_TOOL_INPUT_SCHEMA.properties.testCaseDrafts.items as any).required;
  expect(devRequired).toContain('categoryCode');
  expect(tcRequired).toContain('categoryCode');
});
```

- [ ] **Step 4: Map categoryCode in DecomposeReportModal**

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

```ts
import { resolveCategoryIdFromCode, findCategoryByCode } from '@/lib/task-category';
```

For dev tasks:

```ts
const categoryId = resolveCategoryIdFromCode(categories, draft.categoryCode, 'development');
```

For test cases:

```ts
const categoryId = resolveCategoryIdFromCode(categories, draft.categoryCode, 'testing');
```

Pass `categoryId` into `createTestCase`.

Display category label:

```tsx
<span className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--bg-subtle)] text-[var(--ink-soft)]">
  {findCategoryByCode(categories, d.categoryCode)?.name ?? d.categoryCode}
</span>
```

Remove `ROLE_LABEL[d.role]` usage for AI dev drafts.

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

Run:

```bash
pnpm --filter server test
pnpm --filter server type-check
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 Browser Smoke Check

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

- [ ] **Step 1: Run full focused 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: Smoke check frontend routes**

If dev server is already running, run:

```bash
curl http://localhost:3000/versions
curl http://localhost:3000/admin/categories
```

Expected: both return HTML and HTTP 200.

- [ ] **Step 3: Manual browser checks**

Open `http://localhost:3000/versions`, choose any existing version card, enter its detail page, and verify:

- Research/Product/UI plan creation shows associated requirements and select-all.
- Candidate requirements are only current project adopted requirements.
- Plan cards do not show the top-right direct completion check.
- Plan completion requires subtasks and result.
- Test case create modal requires task type.
- AI report modal shows category labels for dev tasks and test cases.

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

Run:

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

Expected: only intended implementation files changed since the last commit.

- [ ] **Step 5: Commit final verification fixes if any**

Only if Step 1-4 revealed small fixes, apply the focused patch, rerun the failed verification command, inspect `git diff --name-only`, and stage only the concrete files changed by that fix. Use the same commit message:

```bash
git commit -m "fix(版本模块): 修正规则接入验证问题"
```

If no fixes were needed, do not create an empty commit.
