[
  {
    "area": "projects-list+create",
    "severity": "HIGH",
    "component": "apps/web/components/ProjectWizard.tsx (StepProtagonist + StepPremise)",
    "symptom": "Step 4 (主角/金手指) and Step 3 (立意/premise) edit the SAME form field form.premise. Whatever the user types in step 4 overwrites their step-3 立意 input (and vice-versa if they go back). The '主角/金手指概要' and '立意' cannot coexist; only the last-edited survives into POST /projects.",
    "evidence": "StepPremise: value={form.premise} onChange={(e)=>update({premise:e.target.value})}. StepProtagonist: value={form.premise} onChange={(e)=>update({premise:e.target.value})} — identical binding. WizardForm has no separate protagonist field.",
    "repro": "Open /projects/new → fill title (step1) → next to step3, type 立意 text → next to step4, type 主角设定 text → submit. Resulting project.premise contains only the step-4 text; the step-3 立意 is lost. (Confirmed by code; needs-browser to screenshot.)"
  },
  {
    "area": "projects-list+create",
    "severity": "LOW",
    "component": "apps/web/components/ProjectWizard.tsx submit (error toast)",
    "symptom": "On create failure the user always sees the same generic toast '创建作品失败，请重试' regardless of cause (422 validation vs 503 vs 5xx). Validation detail from backend ({detail:[...]}) is discarded.",
    "evidence": "if (error || !data) { toast('创建作品失败，请重试','error') } — no inspection of error.detail/error.error.message.",
    "repro": "Force a 422 (only reachable if title bypasses client guard) → toast shows generic message; backend's specific reason not shown."
  },
  {
    "area": "projects-list+create",
    "severity": "LOW",
    "component": "backend POST /projects validation",
    "symptom": "Whitespace-only title accepted (HTTP 201). min_length=1 does not strip whitespace, so '   ' creates a blank-looking project.",
    "evidence": "curl POST {\"title\":\"   \"} → HTTP 201, title:'   '. Frontend trims so not reachable via wizard, but API contract is permissive.",
    "repro": "curl -X POST /projects -d '{\"title\":\"   \"}' → 201."
  },
  {
    "area": "outline-editor",
    "severity": "MEDIUM",
    "component": "apps/web/components/outline/OutlineEditor.tsx (volume input) + lib/api/server.ts fetchOutline",
    "symptom": "Volume selector is misleading: it only parameterizes POST generate, but GET /outline ignores the volume filter and the editor always renders ALL volumes. After generating into volume N, the page still shows every volume; user has no way to view a single volume despite the picker.",
    "evidence": "curl 'GET /projects/71b3.../outline?volume=2' returns count: 30 (filter ignored). OutlineEditor.tsx:24 groupByVolume(chapters) renders every group; volume state (line 23) is used only at generate() line 50.",
    "repro": "Open outline page; change 卷号 to 2; observe all volumes still listed; GET endpoint takes no volume query."
  },
  {
    "area": "outline-editor",
    "severity": "MEDIUM",
    "component": "apps/web/lib/outline/useOutline.ts:44-49",
    "symptom": "422 validation errors from POST /outline are not surfaced with their detail — the hook reads env.error.code but FastAPI 422 uses {detail:[...]} (no .error), so it always falls back to generic 'OUTLINE_FAILED' / '大纲生成失败，请重试。'",
    "evidence": "POST {volume:0} -> 422 {detail:[{type:greater_than_equal,...}]}; hook code: const env = apiError as {error?:{code?,message?}}; code = env?.error?.code ?? 'OUTLINE_FAILED'. openapi: 422 schema is HTTPValidationError (detail[]), business errors use ErrorEnvelope (error{code}).",
    "repro": "Trigger POST /outline with volume=0 (UI clamps to >=1 so only reachable via direct API or a non-clamped path); error banner shows generic message, loses validation cause."
  },
  {
    "area": "outline-editor",
    "severity": "LOW",
    "component": "apps/web/lib/outline/useOutline.ts:55-57 + OutlineEditor empty state",
    "symptom": "Generation that yields zero chapters still reports success: status=ready, toast '大纲已生成', but the page renders the '暂无大纲' empty placeholder. Misleading success for an effectively no-op generate (e.g. project lacking premise/structure).",
    "evidence": "POST {volume:1} on a content-less throwaway project returned HTTP 200 {chapters:[]}; nothing persisted (subsequent GET count: 0). Hook setChapters([]) + success toast; volumes.length===0 -> placeholder shown.",
    "repro": "Create project with no premise/theme/structure, POST /outline; observe 200 + empty chapters; UI would toast success yet show empty state."
  },
  {
    "area": "outline-editor",
    "severity": "LOW",
    "component": "outline area (task scope vs implementation)",
    "symptom": "QA scope expects PUT edits (add/remove/reorder chapter, beats, foreshadow_windows) and optimistic-save/rollback, but none exist — outline is generate-and-display only, fully read-only after generation.",
    "evidence": "openapi /projects/{id}/outline exposes only get+post; schema.d.ts line 295 put?: never. OutlineChapterRow.tsx renders static beats/badges + a '写此章' Link; no inputs, no save handler.",
    "repro": "Inspect components — no editable fields, no PUT call anywhere; openapi has no PUT/PATCH/DELETE for outline."
  },
  {
    "area": "rules",
    "severity": "HIGH",
    "component": "apps/api/ww_api/routers/rules.py + packages/core/ww_core/domain/rule_repo.py",
    "symptom": "POST a rule to a non-existent project_id returns HTTP 500 (generic INTERNAL) instead of 404.",
    "evidence": "curl -X POST http://localhost:8000/projects/00000000-0000-0000-0000-000000000000/rules -d '{\"level\":\"project\",\"content\":\"x\"}' -> 500 {\"error\":{\"code\":\"INTERNAL\",\"message\":\"服务器内部错误\",...}}. SqlRuleWriteRepo.create (rule_repo.py:44-49) inserts+flushes with no existence check; the FK violation escapes as 500.",
    "repro": "POST /projects/{random-uuid}/rules with a valid body -> observe 500. Expected 404 (project not found)."
  },
  {
    "area": "rules",
    "severity": "MEDIUM",
    "component": "apps/api/ww_api/schemas/rules.py + routers/rules.py",
    "symptom": "Whitespace-only rule content is accepted and persisted (201). Server min_length:1 validates the raw string before trimming; no strip on the server.",
    "evidence": "curl -X POST .../rules -d '{\"level\":\"project\",\"content\":\"   \"}' -> 201; subsequent GET returns {\"level\":\"project\",\"content\":\"   \"}. schemas/rules.py:21 content: str = Field(min_length=1) with no strip/validator.",
    "repro": "POST a rule whose content is only spaces -> 201, junk row stored. (Frontend trims client-side so its own UI won't send this, but the API contract is permissive and the row is unremovable — see DELETE gap.)"
  },
  {
    "area": "rules",
    "severity": "MEDIUM",
    "component": "apps/api/ww_api/routers/generation.py (list_rules)",
    "symptom": "GET rules for a non-existent project returns 200 {\"rules\":[]} instead of 404, masking bad project ids.",
    "evidence": "curl http://localhost:8000/projects/00000000-0000-0000-0000-000000000000/rules -> 200 {\"rules\":[]}. generation.py:377-384 queries rules by project_id with no project-existence check. Note the rules ROUTE page (app/.../rules/page.tsx) calls fetchProject first and would 404 in the UI, but the API itself does not.",
    "repro": "GET /projects/{random-uuid}/rules -> 200 empty list."
  },
  {
    "area": "rules",
    "severity": "MEDIUM",
    "component": "rules API surface (no router)",
    "symptom": "No DELETE (or edit) endpoint for rules — CRUD is create+read only. A mistakenly-added rule (e.g. the whitespace row above) cannot be removed via API or UI.",
    "evidence": "grep for delete in routers/rules.py + rule_repo.py = none; openapi.json exposes only POST+GET on /projects/{project_id}/rules. RulesPage.tsx renders rules as plain <li> with no delete control.",
    "repro": "Add any rule, then attempt to remove it: no endpoint/UI exists."
  },
  {
    "area": "rules",
    "severity": "LOW",
    "component": "apps/web/components/rules/RulesPage.tsx",
    "symptom": "Rule-content textarea has no accessible label (only a placeholder), failing the label/role a11y bar; screen readers announce no field name.",
    "evidence": "RulesPage.tsx:62-68 <textarea> has placeholder but no <label htmlFor>/aria-label/id. The level <select> (lines 47-60) is correctly wrapped in a <label>.",
    "repro": "Inspect the 新增规则 form; the textarea has no programmatic label. needs-browser to confirm SR announcement but verifiable from source."
  },
  {
    "area": "rules",
    "severity": "LOW",
    "component": "apps/web/components/rules/RulesPage.tsx",
    "symptom": "Rule list uses array index as React key (key={i}); reordering/optimistic-replace can cause subtle reconciliation issues.",
    "evidence": "RulesPage.tsx:92 key={i}. Root cause is the contract: RuleView (GET) returns only {level,content} with no id, so no stable key is available.",
    "repro": "Code review; would need GET to expose a rule id to fix properly."
  },
  {
    "area": "foreshadow-board",
    "severity": "HIGH",
    "component": "apps/web/app/projects/[id]/foreshadow/page.tsx (running dev server)",
    "symptom": "Foreshadow board page does not render — returns Next.js _error page with statusCode 500 instead of the kanban board",
    "evidence": "curl http://localhost:3000/projects/71b3725e-25a7-4f12-ba1b-d4ea61fd92e8/foreshadow → __NEXT_DATA__ {pageProps:{statusCode:500}}, err.message: 'Cannot find module ./vendor-chunks/openapi-fetch@0.13.8.js' from .next/server/app/projects/[id]/foreshadow/page.js. Source + node_modules (openapi-fetch@0.13.8) are correct, so this is a stale .next build cache for this route, not a source defect. Other routes (/, /projects) render OK.",
    "repro": "Open http://localhost:3000/projects/71b3725e-25a7-4f12-ba1b-d4ea61fd92e8/foreshadow in browser; observe error page. Likely cleared by rm -rf apps/web/.next and restarting dev server."
  },
  {
    "area": "foreshadow-board",
    "severity": "MEDIUM",
    "component": "backend PATCH /projects/{id}/foreshadow/{code} (consumed by lib/foreshadow/useForeshadow.transition)",
    "symptom": "Invalid to_status value returns HTTP 500 INTERNAL instead of 422 VALIDATION; frontend cannot map it to a friendly reason",
    "evidence": "curl -X PATCH .../foreshadow/FS-01 -d '{\"to_status\":\"BOGUS\"}' → 500 {error:{code:INTERNAL,details:{}}}. Same for 'open' (case) and 'PARTIAL ' (trailing space). to_status is a free string in ForeshadowTransitionRequest schema. UI buttons only emit valid NEXT_STATUS values so it is not directly reachable via the board UI, but the contract accepts arbitrary strings and useForeshadow has no special handling — it would rollback + show generic '操作未通过校验' toast.",
    "repro": "PATCH any existing foreshadow with body {\"to_status\":\"BOGUS\"} → 500. Backend should validate to_status against the status enum and return VALIDATION (reason: invalid_status) like the GET filter does."
  },
  {
    "area": "foreshadow-board",
    "severity": "LOW",
    "component": "backend POST /projects/{id}/foreshadow (RegisterForm path)",
    "symptom": "Whitespace-only code is accepted server-side, creating a foreshadow row with a blank-looking code",
    "evidence": "curl -X POST .../foreshadow -d '{\"code\":\"   \",\"title\":\"空白代号\"}' → 201, code stored as '   '. minLength:1 passes because spaces count. RegisterForm.tsx trims client-side (canSubmit uses form.code.trim()), so unreachable via UI, but the API boundary does not trim/reject — could break code-uniqueness assumptions and renders an empty code in ForeshadowCard.",
    "repro": "POST foreshadow with code of only spaces → 201; GET shows a row with effectively empty code."
  },
  {
    "area": "foreshadow-board",
    "severity": "LOW",
    "component": "apps/web/components/foreshadow/RegisterForm.tsx (toInt)",
    "symptom": "Non-integer numeric input is silently dropped with no validation feedback",
    "evidence": "RegisterForm.tsx:180 toInt() returns null for non-integer (e.g. 'abc' or '1.5' → Number.parseInt yields NaN/truncates); the field is then omitted from the request body with no user-facing message. inputMode='numeric' on type='text' does not enforce digits.",
    "repro": "Open register form, type 'abc' in 埋设章, submit → planted_at silently omitted, no error shown."
  },
  {
    "area": "templates",
    "severity": "LOW",
    "component": "apps/web/components/toolbox/TemplateFiller.tsx:23-40",
    "symptom": "After a transient GET /templates failure, the filler shows the misleading empty-state '（暂无模板，去模板库新建。）' and never retries on reopen.",
    "evidence": "load() catch/error branch does setTemplates([]); useEffect guard `if (open && templates === null) void load()` only fires when templates is null, so [] from a failure is sticky. A real empty list and a failed load are indistinguishable to the user, and reopening the panel won't re-fetch.",
    "repro": "Open generator with brief/text field -> click '从模板填入' while API is down (or returns error) -> toast '加载模板失败' + shows '暂无模板'. Bring API back, click 收起 then reopen -> still shows '暂无模板', no refetch. Templates that do exist are hidden until full page reload."
  },
  {
    "area": "templates",
    "severity": "LOW",
    "component": "apps/web/components/templates/TemplatesManager.tsx:42-57 / backend",
    "symptom": "No max length / no client truncation on title and body; 5000-char title accepted (201).",
    "evidence": "POST title with 5000 'A' chars -> HTTP 201. Schema has minLength:1 but no maxLength; frontend does not cap. Unbounded text could bloat list rendering (each body rendered in full with whitespace-pre-wrap).",
    "repro": "POST /templates with a very long title/body -> 201; the manager list renders the entire body inline (line 162-164) with no clamp."
  },
  {
    "area": "templates",
    "severity": "LOW",
    "component": "backend POST /templates",
    "symptom": "Unknown/extra request fields are silently accepted (ignored), not rejected.",
    "evidence": "POST {title,body,bogus:'z'} -> 201 (bogus ignored). Not a frontend bug since the typed client never sends extras, but worth noting for input-validation strictness.",
    "repro": "curl -X POST /templates -d '{\"title\":\"X\",\"body\":\"Y\",\"bogus\":\"z\"}' -> 201."
  },
  {
    "area": "settings-providers",
    "severity": "MEDIUM",
    "component": "apps/api settings/providers PUT (backend) + ProvidersSettings.tsx routing editor",
    "symptom": "PUT /settings/providers accepts an arbitrary/unknown tier (e.g. 'bogus') and unknown provider names with no enum/whitelist validation, and persists them. No DELETE endpoint exists and PUT is upsert-only (never removes rows), so a stray tier_routing row can never be cleaned up via API/UI. The frontend renders only fixed TIERS (writer/analyst/light) via toRoutingDrafts, so the orphan row is invisible in the UI yet remains in the gateway routing config.",
    "evidence": "curl PUT '{\"tier_routing\":[{\"tier\":\"bogus\",\"provider\":\"anthropic\",\"model\":\"x\"}]}' -> HTTP 200, then GET shows tiers ['analyst','light','writer','bogus']. Re-PUT of the 3 canonical tiers left 'bogus' present; had to delete it directly from the DB tier_routing table.",
    "repro": "PUT /settings/providers with a tier_routing entry whose tier is not in {writer,analyst,light}; observe it persists in GET and cannot be removed via any endpoint."
  },
  {
    "area": "settings-providers",
    "severity": "LOW",
    "component": "apps/web/components/settings/ProvidersSettings.tsx (testConnection)",
    "symptom": "The 'test connection' result loses the backend's specific failure reason. A 404 NOT_FOUND (provider not configured) and a 503 LLM_UNAVAILABLE (probe failed) both surface as the same generic toast, so the user can't tell they simply need to add a key first.",
    "evidence": "ProvidersSettings.tsx:107-108 `if (error || !data) { toast(\"测试连接失败\", \"error\"); return; }`. curl confirmed openai returns 404 and kimi returns 503 with distinct codes/messages.",
    "repro": "On the providers page, click test-connection for an unconfigured provider vs a configured-but-unreachable one; both show identical generic error toast."
  },
  {
    "area": "settings-providers",
    "severity": "LOW",
    "component": "apps/api oauth/start background poller + jobs table",
    "symptom": "Each POST oauth/start makes a real external call to kimi.com and creates a kimi_oauth job whose background poller keeps the row status='queued' for the full device-code lifetime (expires_in=1800s). If the user starts a connect and never authorizes, the job lingers ~30 min before failing.",
    "evidence": "POST start -> 202 with real user_code from https://www.kimi.com/code/authorize_device; jobs row stayed status='queued' and DELETE FROM jobs did not take effect while the in-process poller held it; will self-expire.",
    "repro": "POST /settings/providers/kimi-code/oauth/start and do not authorize; the kimi_oauth job remains queued up to 1800s."
  },
  {
    "area": "codex (设定库) — CodexPage + world entities/characters read & display",
    "severity": "MEDIUM",
    "component": "backend character persist/read (consumed by CodexPage via fetchCharacters) + CodexPage.tsx",
    "symptom": "Character relations are silently dropped on the read path; CodexPage also never displays relations for persisted characters",
    "evidence": "POST /projects/{id}/characters with relations:[{name:'乙',kind:'友',note:'n'}] -> GET /projects/{id}/characters returns relations:[]. CharacterRelationView exists in the OpenAPI contract and CharacterCardItem.tsx renders relations at preview-time, but they are lost once persisted and the codex list omits them entirely (renders only name（role）).",
    "repro": "curl -X POST :8000/projects/$PID/characters -d '{\"cards\":[{\"name\":\"甲\",\"role\":\"主角\",\"backstory\":\"b\",\"arc\":\"a\",\"relations\":[{\"name\":\"乙\",\"kind\":\"友\",\"note\":\"n\"}]}]}' then curl :8000/projects/$PID/characters -> relations:[]"
  },
  {
    "area": "codex (设定库) — CodexPage + world entities/characters read & display",
    "severity": "MEDIUM",
    "component": "backend character ingest (POST /projects/{id}/characters) consumed by codex",
    "symptom": "Character ingest is not idempotent — re-ingesting the same card name creates a duplicate persisted row, which renders as duplicate chips in the codex list",
    "evidence": "Single fresh project: 1st ingest of '甲' -> count 1; 2nd identical ingest -> count 2. CodexPage's mergeCharacterCards only dedups persisted-vs-session by name, not duplicates already in the persisted list, so both rows render.",
    "repro": "POST same {cards:[{name:'甲',...}]} twice to a fresh project, then GET characters -> count 2; load /projects/$PID/codex -> two '甲（主角）' chips"
  },
  {
    "area": "codex (设定库) — CodexPage + world entities/characters read & display",
    "severity": "LOW",
    "component": "apps/web/components/codex/CodexPage.tsx (characters tab)",
    "symptom": "Persisted-character list is a minimal chip (name + role only); traits/backstory/arc/speech_tics/tags/relations from CharacterCardView are never surfaced in the codex, so most ingested data is invisible after refresh",
    "evidence": "CodexPage lines 91-98 render only `{c.name}（{c.role}）`; the richer CharacterCardItem is used only in the generator preview, not the persisted list",
    "repro": "Ingest a character with full traits/tags, reload codex characters tab -> only 'name（role）' shown"
  },
  {
    "area": "codex (设定库) — CodexPage + world entities/characters read & display",
    "severity": "LOW",
    "component": "environment / apps/web .next dev build (blocks browser QA of codex)",
    "symptom": "Every SSR route (codex, outline, projects list, settings) returns HTTP 500 from the running dev server",
    "evidence": "500 body err: 'Cannot find module ./vendor-chunks/openapi-fetch@0.13.8.js' (Require stack -> .next/server/app/projects/[id]/codex/page.js). vendor-chunks dir has no openapi-fetch chunk. home is 200. This is a stale .next cache, not codex source.",
    "repro": "curl :3000/projects/$PID/codex -> 500; needs `rm -rf apps/web/.next` + restart `pnpm dev` to verify codex in-browser"
  },
  {
    "area": "skills",
    "severity": "HIGH",
    "component": "GET /skills backend registry + apps/web/components/skills/SkillsPage.tsx",
    "symptom": "Skills registry is empty in the running environment, so the SkillsPage always shows the empty state '（暂无已注册技能）'. None of the page's intended content (builtin/custom/community grouping, tier badges, genre badge, reads/writes badges) is ever displayed.",
    "evidence": "curl -s http://localhost:8000/skills -> {\"skills\":[]} (HTTP 200). list_skills (generation.py:388) iterates registry.names(); registry is loaded from the `skills` DB table only (skill_registry.py SqlAlchemySkillRepo.list_all = select(Skill)). Initial migration 220ca2e3d53f creates the `skills` table but seeds no rows, and there is no POST /skills endpoint to populate it (openapi paths: /skills GET only).",
    "repro": "curl -s http://localhost:8000/skills  ->  {\"skills\":[]}.  Then open /projects/<PID>/skills -> renders only the '暂无已注册技能' paragraph."
  },
  {
    "area": "skills",
    "severity": "HIGH",
    "component": "apps/web/app/projects/[id]/skills/page.tsx (and all backend-data routes)",
    "symptom": "The skills page returns HTTP 500 instead of rendering; the page cannot be viewed in the running app.",
    "evidence": "GET http://localhost:3000/projects/71b3725e-25a7-4f12-ba1b-d4ea61fd92e8/skills -> 500. __NEXT_DATA__.err.message = \"Cannot find module './vendor-chunks/openapi-fetch@0.13.8.js'\" with require stack through .next/server/app/projects/[id]/skills/page.js. Same 500 reproduces on sibling routes /toolbox and /outline; home (/) is 200. Root cause is a stale/incomplete .next dev build (missing openapi-fetch vendor chunk), shared infra rather than skills code, but it fully blocks the skills page.",
    "repro": "curl -s -o /dev/null -w '%{http_code}' http://localhost:3000/projects/71b3725e-25a7-4f12-ba1b-d4ea61fd92e8/skills  ->  500.  Likely fixed by clearing .next and restarting the web dev server."
  },
  {
    "area": "skills",
    "severity": "LOW",
    "component": "GET /projects/{id} (consumed by skills page fetchProject)",
    "symptom": "Malformed (non-UUID) project id returns 422 rather than 404. The frontend page.tsx catches all errors and maps to notFound() so the user impact is none, but the API semantics differ from a clean 404.",
    "evidence": "curl /projects/does-not-exist -> 422 uuid_parsing; curl /projects/00000000-0000-0000-0000-000000000000 -> 404. page.tsx wraps both in try/catch -> notFound(), so both surface as Next 404.",
    "repro": "curl -s http://localhost:8000/projects/does-not-exist  ->  422 detail uuid_parsing."
  },
  {
    "area": "review-display",
    "severity": "HIGH",
    "component": "apps/web .next dev build (environment) — blocks app/projects/[id]/review/page.tsx and all SSR pages",
    "symptom": "Review report page (and every server-rendered page) returns HTTP 500; browser shows blank, no review content renders.",
    "evidence": "GET http://localhost:3000/projects/{PID}/review?chapter=1 -> 500. Console: 'Error: Cannot find module ./vendor-chunks/openapi-fetch@0.13.8.js' in .next/server/webpack-runtime.js. Confirmed: .next/server/vendor-chunks/ contains only @swc+helpers and next chunks, NO openapi-fetch chunk. outline/foreshadow pages also 500 (same module). Backend API itself is healthy (200s).",
    "repro": "Open http://localhost:3000/projects/71b3725e-25a7-4f12-ba1b-d4ea61fd92e8/review?chapter=1 in browser, or curl it -> 500. Fix: stop dev server, rm -rf apps/web/.next, restart pnpm dev (cache rebuild). Likely a stale incremental build, not a source bug."
  },
  {
    "area": "review-display",
    "severity": "MEDIUM",
    "component": "components/style/StylePanel.tsx + components/review/ReviewReport.tsx (segmentText)",
    "symptom": "Style drift segments whose idx exceeds the chapter's paragraph count render a clickable '第 N 段 / 一键回炉' entry that silently no-ops (cannot locate text).",
    "evidence": "ch2 GET reviews: style.segments has 100 items with idx 0..99, but ch2 draft splits into only 90 paragraphs (/\\n{2,}/). StylePanel renders all 100 as '第 {idx} 段'. ReviewReport.segmentText(idx) = finalParas[idx]?.trim() ?? '' returns '' for idx>=90; RefineView guards empty (RefineView.tsx:31 only refines if segment.trim().length>0, :56 shows empty state). So clicking 回炉 on idx 90-99 does nothing — no feedback, no toast.",
    "repro": "Render ch2 review (once SSR fixed), scroll style panel to '第 90 段'+, click 一键回炉 — RefineView opens with empty-segment state, no AI call, no explanation. Backend produced more style segments than the draft has paragraphs (idx/paragraph contract mismatch)."
  },
  {
    "area": "review-display",
    "severity": "LOW",
    "component": "apps/api accept endpoint (consumed by lib/review/useAccept.ts)",
    "symptom": "422 validation errors on accept use raw FastAPI {\"detail\":[...]} shape instead of the project's standard {\"error\":{code,message,request_id}} envelope.",
    "evidence": "POST accept with final_text:'' -> 422 {\"detail\":[{\"type\":\"string_too_short\",\"loc\":[\"body\",\"final_text\"]...}]}. Contrast: 409 and 404 return the standard envelope. useAccept treats any non-CONFLICT_UNRESOLVED error generically so no crash, but the inconsistency means no greppable request_id for 422s and a generic toast.",
    "repro": "POST /projects/{PID}/chapters/1/accept body {\"final_text\":\"\",\"decisions\":[]} -> 422 raw detail. In UI this is unreachable normally (AnnotatedText always has content) but reachable if draft is empty."
  },
  {
    "area": "nav-shell+command",
    "severity": "HIGH",
    "component": "Next.js dev server (.next build cache) — blocks apps/web/lib/api/client.ts consumers",
    "symptom": "Every project-detail route (/projects/<id>/outline, /review, /codex, /toolbox, /rules, /skills, /style, /foreshadow, /write, /chains) returns HTTP 500 in the running app. Home (/), /templates, /settings/providers return 200.",
    "evidence": "Server-rendered error body: 'Cannot find module ./vendor-chunks/openapi-fetch@0.13.8.js' (Require stack: .next/server/webpack-runtime.js). Confirmed the chunk is absent: `ls apps/web/.next/server/vendor-chunks/` lists only @swc+helpers and next chunks, no openapi-fetch. lib/api/client.ts:1 imports openapi-fetch; all 500ing pages transitively import it via lib/api/server.",
    "repro": "1) App running at localhost:3000. 2) curl -o /dev/null -w '%{http_code}' http://localhost:3000/projects/71b3725e-25a7-4f12-ba1b-d4ea61fd92e8/review -> 500. 3) Fix is environmental: stop dev server, `rm -rf apps/web/.next`, restart `pnpm dev`. NOTE: this is a stale dev-build cache artifact, NOT a source-code defect, but it currently breaks the running app and blocks the Browser QA phase for all project pages."
  },
  {
    "area": "nav-shell+command",
    "severity": "MEDIUM",
    "component": "components/command/CommandPalette.tsx",
    "symptom": "Command palette implements a listbox of options with keyboard arrow highlighting but the combobox a11y contract is incomplete — screen readers will not announce which command is highlighted as the user arrows up/down.",
    "evidence": "Input (line 146-155) has aria-controls=\"command-list\" but is not role=\"combobox\" and lacks aria-activedescendant. Option <li> (line 165-177) has role=\"option\" + aria-selected but no id, so there is nothing for aria-activedescendant to point at. grep for 'aria-activedescendant|combobox|id={' in the file returns no matches.",
    "repro": "Open ⌘K, type a query, press ArrowDown with a screen reader (VoiceOver/NVDA) active: the visual highlight moves but no option is announced. needs-browser to confirm AT announcement; code inspection confirms the missing attributes."
  },
  {
    "area": "nav-shell+command",
    "severity": "LOW",
    "component": "components/Toast.tsx",
    "symptom": "Error toasts are announced via a polite live region instead of an assertive one, so failure messages may not interrupt the screen reader.",
    "evidence": "Toast.tsx lines 59-63: the toast container is a single region with aria-live=\"polite\" role=\"status\" used for all kinds (info/success/error). Per ARIA, error/failure feedback should use role=\"alert\" / aria-live=\"assertive\".",
    "repro": "Trigger an error toast (e.g. show('...','error')); with a screen reader it is queued politely rather than interrupting. needs-browser to confirm AT behavior."
  },
  {
    "area": "write-sse (LLM full E2E: chapter draft streaming, autosave, injection panel)",
    "severity": "CRITICAL",
    "component": "apps/api/ww_api/routers/projects.py :: stream_draft (POST /projects/{project_id}/chapters/{chapter_no}/draft)",
    "symptom": "Streaming a draft for a NON-EXISTENT project returns HTTP 200 and a full LLM-generated chapter instead of 404. The stream endpoint never validates project existence before invoking the gateway, so an invalid project ID silently burns a real (paid, rate-limited) LLM call and emits token+done events as if valid.",
    "evidence": "curl -X POST /projects/00000000-0000-0000-0000-000000000000/chapters/7/draft → HTTP 200 ct=text/event-stream, streamed a generic chapter ('由于您未提供前六章内容...'), terminated with `event: done {\"length\":2606}`. Compare: GET/PUT /injection and GET /draft on the SAME bad project ID correctly return 404 {error:{code:NOT_FOUND}}. Root cause: stream_draft calls assemble(repos, project_id, ...) which does not raise on missing project; no explicit existence guard (projects.py lines 263-283).",
    "repro": "BAD=00000000-0000-0000-0000-000000000000; curl -sN -X POST http://localhost:8000/projects/$BAD/chapters/7/draft -H 'Accept: text/event-stream' -w '\\nHTTP %{http_code}\\n' — observe HTTP 200 + token/done events instead of 404."
  },
  {
    "area": "write-sse (LLM full E2E: chapter draft streaming, autosave, injection panel)",
    "severity": "LOW",
    "component": "lib/stream/useDraftStream.ts (pre-stream error branch)",
    "symptom": "On a pre-stream failure (e.g. 503 LLM_UNAVAILABLE returned as a JSON error envelope), useDraftStream extracts error.code and error.message but ignores error.request_id present in the envelope, so the correlation id is dropped from the UI/error state. Violates the CLAUDE.md logging rule that request_id should travel end-to-end for greppability.",
    "evidence": "useDraftStream.ts lines 99-110: only `body.error?.code` and `body.error?.message` are read; no request_id capture. The StreamState.error type and the mid-stream `error` event DO carry request_id, so the two error paths are inconsistent.",
    "repro": "Configure no LLM provider (or force 503) then click 写本章; the surfaced error has no request_id. Could not force live (provider configured)."
  },
  {
    "area": "write-sse (LLM full E2E: chapter draft streaming, autosave, injection panel)",
    "severity": "LOW",
    "component": "apps/api (FastAPI validation) vs frontend hooks",
    "symptom": "Path/body validation errors (422) return FastAPI's default {detail:[...]} shape, NOT the project's {error:{code,message,request_id}} envelope used by 404/503. Frontend hooks degrade gracefully (generic message), but the inconsistent envelope means 422s have no request_id and no error code for friendlyError mapping.",
    "evidence": "GET /injection chapter_no=abc → 422 {detail:[{type:int_parsing,...}]}; PUT recent_n=0 → 422 {detail:[{type:greater_than_equal,...}]}. Contrast 404 → {error:{code:NOT_FOUND,...,request_id}}.",
    "repro": "curl /projects/$PID/chapters/abc/injection → 422 {detail:..}."
  },
  {
    "area": "toolbox-generators (LLM full E2E)",
    "severity": "LOW",
    "component": "apps/web/lib/generation/cards.ts:139 generationErrorMessage",
    "symptom": "Server-side VALIDATION (422) errors from generate (e.g. text too short, kind invalid) are shown to the user as the generic '生成失败，请稍后重试。' rather than the specific backend message (e.g. '工具 de-ai 需要原文输入').",
    "evidence": "generationErrorMessage only branches on errorCode===LLM_UNAVAILABLE; all other codes (VALIDATION/NOT_FOUND) fall through to generic text. Backend returns a clear message in error.message that is discarded.",
    "repro": "Bypass client validation (or send a server-only-invalid value) -> generate -> toast shows generic message instead of the actionable backend reason. Mitigated because GeneratorRunner.onGenerate blocks empty required fields client-side first."
  },
  {
    "area": "toolbox-generators (LLM full E2E)",
    "severity": "LOW",
    "component": "backend POST /projects/{id}/skills/{tool_key}/ingest (glossary/world_entities)",
    "symptom": "Empty ingest payload (world_entities:[]) returns HTTP 201 with created:[] instead of a 4xx, i.e. a no-op 'success'.",
    "evidence": "curl with {\"world_entities\":[],\"acknowledge_conflicts\":false} -> HTTP 201 {table:world_entities, created:[]}. ",
    "repro": "POST ingest with empty array. Mitigated: GeneratorRunner.runIngest is gated by selected.size===0 / hasPreview and useGenerator.ingest returns early with toast '请至少选择一项入库。' when rows.length===0, so the empty body never leaves the client in normal use."
  },
  {
    "area": "LLM full E2E — style (fingerprint extraction + drift + refine/de-AI)",
    "severity": "HIGH",
    "component": "ReviewReport.tsx (segmentText) + packages/agents/ww_agents/prompts/style.md + review_node.build_review_context",
    "symptom": "One-click 回炉 can target the wrong paragraph (or an empty/out-of-range one) because the drift-segment idx produced by the style-auditor LLM and the frontend's paragraph index are derived by two unrelated splitting schemes.",
    "evidence": "Backend feeds the draft verbatim to the reviewer (review_node.py:59-64 build_review_context — no injected paragraph numbering); prompt style.md instructs the LLM to number segments '段索引0起,与正文切分顺序一致' but never pins the split rule. Frontend ReviewReport.tsx:344-346 computes finalParas = finalText.split(/\\n{2,}/) and segmentText(idx)=finalParas[idx]?.trim() ?? '''. If the LLM segments differently (single-newline / sentence) the idx points to the wrong para; RefineView then either refines the wrong text or shows '该段在终稿中为空'.",
    "repro": "Learn a fingerprint, write a chapter whose draft uses single-newline separation between logical segments, run review until style returns segments with idx>0, click '一键回炉' on a drift segment — observe the refined text is for a different paragraph than the one flagged. Needs a learned fingerprint + drift (integration/browser); could not deterministically trigger because existing PID 71b3... has no fingerprint (style review degrades to score=100/segments=[])."
  },
  {
    "area": "LLM full E2E — style (fingerprint extraction + drift + refine/de-AI)",
    "severity": "LOW",
    "component": "apps/api/ww_api/routers/style.py refine_segment (line 198-240)",
    "symptom": "Refine endpoint does not validate that the chapter exists; any chapter_no (even nonexistent) returns 200.",
    "evidence": "POST /projects/71b3.../chapters/99/refine {\"segment\":\"测试段落\"} → HTTP 200 {\"original\":\"测试段落\",\"refined\":\"测试段落\"}. In code chapter_no is only used in log.info, never to look up/validate the chapter.",
    "repro": "curl -X POST .../chapters/99/refine -d '{\"segment\":\"x\"}' → 200 instead of 404. Low impact because segment text is supplied by the client and refine is read-only (invariant #3)."
  },
  {
    "area": "LLM full E2E — style (fingerprint extraction + drift + refine/de-AI)",
    "severity": "LOW",
    "component": "apps/web/components/style/StyleUpload.tsx (line 101-104) + apps/api job_runner",
    "symptom": "Style-learn progress indicator is effectively static: shows '提取文风指纹中…（0%）' the entire time then jumps to done.",
    "evidence": "GET /jobs/{id} reports progress 0 for all polls while queued, then 100 at done — no intermediate values; StyleUpload binds progress directly.",
    "repro": "Submit learn, watch poll output: every poll progress=0 until the single done poll at 100."
  },
  {
    "area": "LLM full E2E — style (fingerprint extraction + drift + refine/de-AI)",
    "severity": "LOW",
    "component": "apps/web/lib/style/useStyleLearn.ts (done-edge effect, line 53-66)",
    "symptom": "Potential stale done-edge re-fire on a second 'learn' within the same mounted StylePage before the new job's first tick lands; could toast '文风指纹已更新' / refetch prematurely.",
    "evidence": "Effect deps [poll.status, poll.error, toast]; reducePoll has no reset action, so state stays 'done' from run 1 after learn() calls poll.poll(). The intervening setPollStatus('polling') in learn (line 89) mitigates the visible state but the done branch keyed on poll.status only flips on first tick. Edge timing.",
    "repro": "needs-browser: learn once, wait done, then learn again and observe whether a success toast fires before the new extraction completes."
  },
  {
    "area": "LLM full E2E — style (fingerprint extraction + drift + refine/de-AI)",
    "severity": "LOW",
    "component": "apps/web/components/style/RefineView.tsx (effect deps line 30-36)",
    "symptom": "Switching between two drift segments whose mapped paragraph text is identical (e.g. both out-of-range → '') will not re-trigger refine; stale prior result/state shown.",
    "evidence": "useEffect deps are [segment, projectId, chapterNo]; if the selected segment's text string is unchanged the refine call is skipped.",
    "repro": "needs-browser: two drift segments resolving to the same segmentText; click one then the other — second shows first's result or no refresh."
  },
  {
    "area": "LLM full E2E — chain (multi-chapter workflow chain)",
    "severity": "MEDIUM",
    "component": "apps/web/components/chain/ChainPage.tsx:61 & apps/web/components/chain/ChainAdjudication.tsx:102",
    "symptom": "Chain run/resume failures collapse to a generic toast ('出错了，请稍后重试。'); the structured error code/message from the API envelope is thrown away. Notably a 503 LLM_UNAVAILABLE (which the run endpoint explicitly returns and which maps to a 'go to settings → connect a provider' action link) and 409 CONFLICT lose all specificity.",
    "evidence": "Both call `friendlyError(undefined)` with no args. openapi-fetch returns the ErrorEnvelope as `error` (schema.d.ts:1128 ErrorEnvelope{error:ErrorBody{code,message}}). Correct pattern exists elsewhere: ReviewReport.tsx:564 `friendlyError(error.code, error.message)` and Workbench.tsx:224. friendlyError/MESSAGES (errors/messages.ts:19-31) with the LLM_UNAVAILABLE provider-action link is effectively dead code on the chain paths.",
    "repro": "On chains page, trigger run with no provider connected (API returns 503 LLM_UNAVAILABLE) → user sees generic '出错了' toast with no 'go to settings' link instead of the actionable LLM_UNAVAILABLE message."
  },
  {
    "area": "LLM full E2E — chain (multi-chapter workflow chain)",
    "severity": "LOW",
    "component": "apps/web/components/chain/ChainAdjudication.tsx:94-110 (+ ChainPage.tsx:39-41)",
    "symptom": "No recovery path if resume returns 409 (job no longer awaiting — e.g. two tabs, or job already resumed/moved). A toast fires and the adjudication panel stays open with all conflicts resolved, but the resume can never succeed and polling was already stopped (poll.reset() ran on entering awaiting). The UI is stuck with no re-sync of job state.",
    "evidence": "onResume only toasts + setResuming(false) on error (ChainAdjudication.tsx:101-105). Backend returns 409 verified: POST resume on done/failed job -> 409 CONFLICT. ChainPage stops polling on awaiting (useEffect poll.reset(), ChainPage.tsx:39-41) and only re-polls via onResumed() which fires on success only.",
    "repro": "Reach awaiting state in two tabs; resume in tab A (job leaves awaiting); resume in tab B → 409 → tab B panel stuck, no way to refresh job status without reload."
  },
  {
    "area": "LLM full E2E — chain (multi-chapter workflow chain)",
    "severity": "LOW",
    "component": "apps/web/components/chain/ChainPage.tsx:39-41",
    "symptom": "The poll.reset() effect lists `poll` in its dependency array, and `poll` is a fresh object every render (useJobPoll returns {...state, poll, reset}); so while phase==='awaiting' the effect re-runs on every render. reset() is idempotent (clears timer) so this is harmless, but it is a needless re-run / latent footgun.",
    "evidence": "useJobPoll returns spread object each render (useJobPoll.ts:87); ChainPage effect deps [phase, poll] (ChainPage.tsx:41).",
    "repro": "Static analysis; observable as repeated clearTimeout calls while awaiting."
  },
  {
    "area": "LLM full E2E — chain (multi-chapter workflow chain)",
    "severity": "HIGH",
    "component": "Next.js dev server (infra) — apps/web/.next cache",
    "symptom": "All /projects/[id]/* SSR pages (including /chains, plus /outline, /foreshadow, /codex, /skills) return HTTP 500 in the running dev server, blocking any browser-level QA of the chain UI.",
    "evidence": "GET http://localhost:3000/projects/<PID>/chains -> 500 (x3). Decoded dev error: \"Cannot find module './vendor-chunks/openapi-fetch@0.13.8.js'\" referenced from .next/server/app/projects/[id]/outline/page.js. openapi-fetch@0.13.8 IS installed in node_modules but .next/server/vendor-chunks/ has no openapi chunk — stale/corrupt Next dev build cache. NOT a chain component defect.",
    "repro": "curl -s -o /dev/null -w '%{http_code}' http://localhost:3000/projects/71b3725e-25a7-4f12-ba1b-d4ea61fd92e8/chains -> 500. Fix: restart dev server or rm -rf apps/web/.next then re-run."
  },
  {
    "area": "Browser smoke test — all 14 routes + key interactions (Next.js web app at localhost:3000)",
    "severity": "CRITICAL",
    "component": "apps/web/.next/server/app/projects/[id]/*/page.js (all 10 project routes)",
    "symptom": "Every project-scoped route returns HTTP 500 with a full-screen Next.js Server Error; the entire authoring surface (outline/write/review/foreshadow/rules/style/codex/skills/toolbox/chains) is unusable",
    "evidence": "curl + browser overlay: \"Error: Cannot find module './vendor-chunks/openapi-fetch@0.13.8.js'\" required from .next/server/webpack-runtime.js. Verified .next/server/vendor-chunks/ contains only @swc+helpers and next chunks — the openapi-fetch vendor chunk is missing from disk, while openapi-fetch@0.13.8 IS installed in node_modules (source is fine; the .next build is corrupt/incomplete)",
    "repro": "curl -s http://localhost:3000/projects/71b3725e-25a7-4f12-ba1b-d4ea61fd92e8/write → 500; or navigate any project route in browser → Server Error dialog"
  },
  {
    "area": "Browser smoke test — all 14 routes + key interactions (Next.js web app at localhost:3000)",
    "severity": "CRITICAL",
    "component": "apps/web next dev server / .next/static chunks (affects all 4 top-level routes)",
    "symptom": "Client JS bundles 404, so React never hydrates — the whole app is a static, non-interactive shell. No button enables, no form submits, no client interaction works on ANY route",
    "evidence": "Network log on /: main-app.js [404], app-pages-internals.js [404], app/layout.js [404], app/page.js [404]; curl confirms persistent 404 for main-app.js, polyfills.js, app-pages-internals.js, layout.js. .next/static/chunks holds production-hashed names (main-app-a84cd174a2d81b10.js, polyfills-42372ed130431b0a.js) but HTML requests dev-unhashed names. Proof of non-hydration: typed a valid title into wizard, evaluate_script returned inputValue='BugCheckTitle' but nextDisabled=true (canAdvance logic is correct, so onChange never fired)",
    "repro": "Open http://localhost:3000/projects/new, type a book name → 下一步 stays disabled; or curl -s -o/dev/null -w '%{http_code}' http://localhost:3000/_next/static/chunks/main-app.js → 404"
  },
  {
    "area": "Browser smoke test — all 14 routes + key interactions (Next.js web app at localhost:3000)",
    "severity": "HIGH",
    "component": "apps/web dev environment (process management)",
    "symptom": "Two concurrent `next dev` processes run against the same apps/web, both writing the shared .next dir — most likely cause of the corrupted/inconsistent build (missing vendor chunks + mixed hashed/unhashed chunk names)",
    "evidence": "ps aux shows PID 58504 and PID 60575 both = `node .../next/dist/bin/next dev` in apps/web, started 3:15pm and 3:16pm",
    "repro": "ps aux | grep 'next dev' | grep apps/web → two PIDs"
  }
]