# 踩坑与约定（append-only）

> 实现中发现的坑、易错点、约定俗成——让兄弟 agent 不重复踩。一条一项，最新在最上。
> 只记**非显而易见**的；规格/CLAUDE.md 已写的别重复。

格式：`- [date] @skill <坑/约定> — 缘由 + 怎么做`

---

- [2026-06-20] @frontend **页面重访回显已存内容用 RSC 读 helper（`lib/api/server.ts`）作初值种入 client 组件，404/错误降级、绝不阻塞进页**（大纲/写作重载）：先前大纲页 `initialChapters=[]`、工作台 `useState("")` → 重访空白虽然库里有数据。范式：① **大纲**：`fetchOutline(projectId)` 包 `getJson<OutlineResponse>` 取 `chapters??[]`，**try/catch 整体降级为 `[]`**（项目存在无大纲后端返 200 空列表，但任何错误也不该让大纲页 500）；`useOutline(initial)` 已据 `initial.length>0` 置 `status="ready"`，传非空即回显，生成流照常覆盖。② **草稿**：`fetchDraft` 直接复用既有 `getJsonOrNull`（**404→null**，后端无草稿行正是 404），页面 `draft?.content ?? ""` 作 `Workbench` 的 `initialText`。③ **不要把初值塞进 stream 的 reducer**——`Workbench` 用 `useState(initialText)` 起步即可：流式那条 `useEffect` 起始 `stream.state.text` 为空、仅当 `phase∈{streaming,done,aborted}` 且文本变化才 `setText`，所以初值不会被空流反扑，SSE+AbortController+PUT 自动保存全不用动。④ 测纯加载/降级逻辑：`lib/api/server.test.ts` 用 `vi.stubGlobal("fetch", …new Response(JSON, {status}))` + `afterEach(vi.unstubAllGlobals)`（node 环境够用，`server.ts` 只依赖 `process.env`/`fetch`），断 200 解包、空/错误→`[]`、404→null。

- [2026-06-19] @qa **Kimi OAuth E2E 要 token 真落 pg：override `get_session_factory`→`e2e_sm`、但**不能** monkeypatch `kimi_oauth.SqlCredentialStore`（K1.3 单测那样会让落库走内存 fake，验不到真 pg）**（K1.5）：与 K1.3 单测（FakeSession/FakeStore/FakeJobRepo，验逻辑）不同，E2E 要验真持久化——让后台 `work` 自建的 `SqlCredentialStore(session)` + `run_job` 默认 `SqlJobRepo` **保持真**，只 override `get_session_factory`→`e2e_sm`（后台 work 用真 session 写真 pg）。仍须：monkeypatch **模块级** `routers.kimi_oauth._default_http_client`→同一 scripted fake（后台 work 自建 http 非 dep）+ `app.dependency_overrides[kimi_oauth._default_http_client]`→同一 fake（端点 device-auth 走 dep；call[0]=device-auth、call[1..]=token 轮询顺序共享）+ monkeypatch `routers.kimi_oauth.asyncio.sleep`→no-op。断「加密非明文」：`_ACCESS_TOKEN.encode() not in row.oauth_enc`（Fernet 密文里找不到明文字节）+ `decrypt_oauth_bundle` 回环。`Gateway` 无公开 adapters accessor → 用 `gateway._adapters[provider]`；`KimiCodeAdapter._client` 读 `.base_url`/`.default_headers`/`.api_key`（同 K1.2 单测）。refresh-on-build 直测 `_build_provider_adapter`（非经 HTTP，更清晰）：字符串 target monkeypatch `"ww_api.services.project_deps.httpx.AsyncClient"` + `setattr(project_deps,"kimi_refresh",fake)`。清理：删 `provider_credentials`(kimi-code) + `jobs`(kind=kimi_oauth) + `tier_routing`(project_id 为 NULL 的全局行)。ruff E501 按**显示宽度**算（中文字符占 2 列）——含中文注释/docstring 的行别贴边 100。

- [2026-06-19] @frontend **Kimi Code OAuth 三个端点无 params/body → openapi-fetch 用 `api.POST(PATH, {})` / `api.GET(PATH)`；路径用 `as const` 字面量常量复用**（K1.4）：`POST .../oauth/start`、`.../oauth/disconnect`、`GET .../oauth/status` 的生成 schema 全是 `path?: never; ...; requestBody?: never`——`api.POST(START, {})`（传空 init obj，typecheck 绿）、`api.GET("/settings/providers/kimi-code/oauth/status")`（无第二参）。把 `start`/`disconnect` 路径抽成模块级 `const START = "/settings/...start"` 给 `api.POST` 时，**字符串字面量类型仍被 openapi-fetch 推断为合法 path key**（无需 `as const`，但抽常量去重 DRY）。② 连接流复用 `useJobPoll`（M4）零改：`connect` 拿 `data.job_id` 调 `poll.poll(job_id)`；轮询终态用 `useEffect([poll.status])` + `startedRef` 守门（同 `useStyleLearn` 先例，`initialPollState.status` 默认 `"polling"` 不能进页即据此显进度）。③ kimi_oauth job 完成态 result 只 `{connected, provider}`（**无 token**）——`jobConnected(job)=job.result?.["connected"]===true`，**别**期待/解析任何 token 字段。④ user_code a11y：用 `<output tabIndex={0} className="select-all">`（可读、可全选复制、可聚焦），别用纯 `<span>`。⑤ 档位路由原是只读展示，K1.4 改成可编辑（per-tier `<select>` provider + model input + 保存 PUT `{tier_routing}`）；选 OAuth provider（kimi-code）经 `applyProviderChange` 自动套 `defaultModel="kimi-for-coding"`，API-key provider 清空 model 待填。
- [2026-06-19] @backend **Kimi OAuth 后台轮询 work 自建 http 走模块级 `_default_http_client()`（非 dep）——测试须 monkeypatch 它 + `job_runner.SqlJobRepo`，不能只 override dep**（K1.3）：`POST .../start` 的 `http` 是 FastAPI dep（端点用），但后台 `work` 在 `run_job` 独立 session 里调**模块级** `routers.kimi_oauth._default_http_client()` 自建 http（请求 dep 已失效）——E2E/单测必 `monkeypatch.setattr("ww_api.routers.kimi_oauth._default_http_client", lambda: fake_http)`（同一 scripted fake 实例供端点 device-auth call[0] + work poll call[1...] 顺序共享）+ `monkeypatch.setattr(job_runner,"SqlJobRepo", lambda s: fake_job_repo)`（run_job 默认 repo_factory 引模块级 SqlJobRepo，FakeSession 无 .execute；同 T4.3 gotcha）+ `monkeypatch.setattr(routers.kimi_oauth,"SqlCredentialStore", lambda s: shared_store)`（work 自建 store 落库）+ override `get_session`→FakeSession/`get_session_factory`→FakeSessionFactory + monkeypatch `routers.kimi_oauth.asyncio.sleep`→no-op（不真等 interval 秒）。TestClient 同步跑完 background task，断言稳定。
- [2026-06-19] @backend **`AsyncHttpClient` 最小 Protocol 故意不含 `aclose`（只 `post`）；router work 关客户端用 `getattr(http,"aclose",None)`**（K1.3）：service 函数（start/poll/refresh）只需 `.post`，给 Protocol 加 `aclose` 会逼所有测试 fake 实现它（mypy red）。`httpx.AsyncClient` 有 `aclose`，故 router `work` 的 `finally` 用 `aclose = getattr(http,"aclose",None); if aclose: await aclose()`——既关真客户端、又不污染最小 Protocol、fake 无需实现 aclose。**测试模块级属性 monkeypatch（`project_deps.httpx`/`kimi_oauth.asyncio`）mypy strict 报 `attr-defined`（模块未 re-export）——用字符串 target `monkeypatch.setattr("pkg.mod.attr.sub", val)`（mypy 不静态校验字符串）规避，别 `setattr(mod.attr, "sub", val)`。**
- [2026-06-19] @llm **Kimi Code 适配器：伪造头走 `AsyncOpenAI(default_headers=…)`、device_id 走 env-或-`uuid5` 确定性缺省（绝不随机）；UA 验证为 `KimiCLI/1.5` 且参考实现未设 X-Msh-***（K1.2）：① coding 端点 OpenAI 兼容 → `KimiCodeAdapter` **直接子类化 `OpenAICompatAdapter`**（零重复 complete/stream/结构化逻辑），区别只在工厂构造的客户端带 `default_headers` + coding base_url + access_token 当 api_key（SDK 自动发 `Authorization: Bearer`）。② `X-Msh-Device-Id` 必须**跨调用稳定**：`kimi_device_id()` = env `KIMI_DEVICE_ID` 优先，否则 `uuid5(NAMESPACE_DNS, "ww.kimi-code.device")` 模块级常量——**别用 `uuid4()` 每次新建**（每次随机 device id 会让 Kimi 侧把每次调用当新设备）。③ **校验对照 `picassio/pi-kimi-coder` `extensions/index.ts`**：它对 coding API 只显式设 `User-Agent: "KimiCLI/1.5"`（精确值，本实现采用）、**没有** X-Msh-* 头——与 PROGRESS K1 契约「缺 X-Msh→403」**不符**。本实现按「UA 必需（已验证）+ X-Msh-* 附带（契约要求、真客户端会发、额外标识无害）」处理；**到底缺 X-Msh 会不会 403 须 K1.3/K1.5 对真 api.kimi.com 联网验证**（含封号风险）。④ 单测断「构造出的 `AsyncOpenAI` 携带正确 `base_url`/`default_headers`/`api_key`」即可，零联网；工厂分派测试访问 `adapter._client` 须先 `isinstance(adapter, KimiCodeAdapter)` 收窄（`build_adapter` 返回 `ProviderAdapter` Protocol 无 `_client`，否则 mypy `attr-defined`）。
- [2026-06-19] @db **K1.1 把 `provider_credentials.api_key_enc` 改 nullable 后，跨边界打穿到 @backend `credentials.py` mypy 报错——这是 K1.3 要吸收的，K1.1 不越权修** — `models.py` 列 `api_key_enc: Mapped[bytes | None]`，但 `apps/api/ww_api/services/credentials.py` 的 `StoredCredential.api_key_enc: bytes`（dataclass）+ `SqlCredentialStore.list_credentials/get_credential` 拿 `r.api_key_enc`（现 `bytes|None`）→ 2 处 `arg-type` 错。@db 只拥有 `packages/db/`，不改 `apps/api/`（目录所有权）。**K1.3 @backend 行动**：`StoredCredential.api_key_enc` 改 `bytes | None`（顺带加 `auth_type`/`oauth_enc` 字段 + OAuth 读写），mypy 即绿。首个新迁移（M1–M5 全零建表）= K1 新功能预期内。
- [2026-06-19] @db **新迁移文件 autogenerate 后须手修两处再过 ruff**：① autogenerate 模板把 `from sqlalchemy.dialects import postgresql` **写两遍**（重复 import，ruff F811/格式炸）→删一行；② 模板用单引号 + import 顺序（`from alembic import op` 在 `import sqlalchemy as sa` 前）不合本仓 ruff（双引号 + isort）→照初始迁移 `220ca2e3d53f` 的样式手排（`import sqlalchemy as sa` → `from alembic import op` → `from sqlalchemy.dialects import postgresql`，双引号，docstring 后空行）。改完跑 `uv run ruff format` + `ruff check` 验。
- [2026-06-19] @llm **真 `anthropic`/`google-genai` SDK 装上后，注入客户端 Protocol 与 SDK 具体类型在 mypy strict 下不互通——在边界 `cast` 解决，别硬改 Protocol**（R2 follow-up #2）：① `instructor.from_anthropic(self._client)` 的重载只收 SDK 具体 `AsyncAnthropic|...` 联合，`AnthropicClient` Protocol 不匹配 → adapter 内 `cast("AsyncAnthropic", self._client)` 选中 AsyncInstructor 重载（`TYPE_CHECKING` 下 import，避免运行时硬依赖 SDK）。② `factory.build_adapter` 把**真实** `AsyncAnthropic`/`genai.Client` 传给 adapter 时，SDK 客户端的 `messages`/`aio` 是 **read-only 属性**，而 Protocol 成员默认 **settable** → mypy 报「expected settable variable, got read-only attribute」；adapter 仅**读取**这些属性，故在工厂里 `cast("AnthropicClient"/"GeminiClient", client)` 跨过约束。**测试替身缝完好**（adapter 仍声明 Protocol 形参，fake 仍可注入）。gemini 无 instructor 那条错（它走 `response_schema` 非 instructor），只有 factory 的 read-only 那条。
- [2026-06-19] @backend **Codex 读端点缺口已补（解紧邻下面这条 @frontend gap）+ skill impl 迁回 `ww_skills` 包**（M5 R1/R3）：① `GET /projects/:id/characters` + `GET /projects/:id/world_entities` 已落（复用 C5 读侧 `SqlCharacterRepo`/`SqlWorldEntityRepo`，反向 JSONB 解包 `{"items":[...]}`→list/`{"text":...}`→str/`{"rules":[...]}`→list，无行返空列表）→ @frontend `pnpm gen:api` 后 CodexPage 初始列表可拉**跨会话全量**，去掉「会话内回显」局限。② skill registry/沙箱从 `ww_core.domain` **迁到** `packages/skills/ww_skills/`——`from ww_core.domain import SkillRegistry/SqlSkillRepo/SkillRecord/SkillRepo/partition_writes/filter_reads/validate_declaration/KNOWN_TABLES` **已失效**，改 `from ww_skills import …`。③ 改 `packages/skills/pyproject.toml` deps（去 ww-core、补 ww-llm-gateway+sqlalchemy）后**必须 `uv sync`** 重建 ww-skills，否则按旧元数据解析。④ `build_gateway_for_tier` 现用 `ww_llm_gateway.build_adapter(provider, api_key=…, base_url=…)`（去掉 apps/api 直接 `AsyncOpenAI`/`OpenAICompatAdapter` import）；anthropic/gemini 传 base_url=None 走原生适配器。
- [2026-06-19] @frontend **【RESOLVED 2026-06-19】Codex 读端点缺口已闭合（M5 R1 follow-up #1）**：@backend 补 `GET /projects/:id/characters` / `GET .../world_entities`（C3 扩，反向 JSONB 解包成 API 友好字段）后，前端 `gen:api` 纳入 `CharacterListResponse{characters?:[CharacterCardView]}` / `WorldEntityListResponse{world_entities?:[WorldEntityCardView]}`（+`lib/api/types` 别名）；`lib/api/server` 加 `fetchCharacters`/`fetchWorldEntities`（无行→空列表，非 404，仿 `fetchForeshadow`）；codex route（Server Component）`Promise.all` 拉项目+人物+世界观全量传初始数据；`CodexPage` 渲染**跨会话持久化真源**（刷新不再空），本会话新入库卡经 `mergeCharacterCards`（纯逻辑，按 name 去重、持久化优先、node 单测）合并补显（不与真源重复）。世界观本期无入库端点，故只渲染真源 + 生成器预览（无 session 合并）。**「会话内回显」局限已去除**。原 T5.3「生成+本次入库会话内回显」管理入口仍保留（generate→ingest 后即时见新行）。
- [2026-06-19] @frontend **入库 409 冲突详情形 = accept gate 的 `details.conflicts`（ReviewConflict 形），不是 missing_conflict_indices**（T5.3）：`POST /characters` 的 409 CONFLICT_UNRESOLVED 信封 `details{conflicts:[{type,where,refs,suggestion}], conflict_count}`（continuity 预检产出，C3 扩 T5.2），区别于 accept 的 `details{missing_conflict_indices, conflict_count}`（裁决覆盖缺口）。前端 `extractIngestConflicts` 按 `error.code==="CONFLICT_UNRESOLVED"` + 收窄 `details.conflicts`（复用 `lib/review/sse` 的 `ReviewConflict` 形 + history.ts 同款安全收窄）；裁决流 = 展示冲突 → 作者确认 → 带 `acknowledge_conflicts=true` 重发（非逐条 conflict_index，整体放行，对齐后端 gate 语义）。错误码同款 503 LLM_UNAVAILABLE 引导去设置（仿 useRefine）。
- [2026-06-19] @frontend **命令面板（⌘K）走全局 keydown + RootLayout 挂载，纯过滤/高亮逻辑抽 `lib/command/palette` node-env 单测**（T5.6）：`CommandPaletteMount`（client）读 `usePathname` 注入项目上下文 → `projectIdFromPath` 正则抽 `/projects/<id>`；命令清单（导航 + 生成动作）+ `filterCommands`(title/keywords 大小写无关子串) + `moveHighlight`/`clampHighlight`(循环) 全是纯函数、node 单测。组件层只管 ⌘K/Ctrl+K toggle（`window.keydown`）、焦点（打开 `inputRef.focus`）、Esc/↑↓/Enter、a11y（role=dialog/listbox/option + aria-selected + motion-safe）。生成动作（生成角色/世界观）= 跳 `codex?gen=character|world`，CodexPage 读 searchParams 直开对应 tab（无需独立 modal 路由）。
- [2026-06-19] @backend **测 `build_gateway_for_tier` 多 provider 接线要用真 Fernet key（不是 `"x"*44`）**（T5.2）：该函数解密凭据建适配器，凭据是 `encrypt_api_key(..., key=CREDENTIAL_ENC_KEY)` 加的密，key 必须是合法 Fernet（32 字节 url-safe base64），否则 `decrypt_api_key` 抛 `CredentialKeyError`。生成一个：`uv run python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"`，设 `os.environ["CREDENTIAL_ENC_KEY"]=<key>` + `get_settings.cache_clear()`（lru_cache）。生成/入库**端点**测试不碰真解密（override 网关 dep 注 schema-routing fake），故那些仍可用 `"x"*44`；只有直接调 `build_gateway_for_tier` 的单测需真 key。
- [2026-06-19] @backend **生成/入库/precheck 端点测试用 schema-routing fake 网关（按 `req.output_schema` 返不同 parsed）**（T5.2）：一次 ingest 请求触发 precheck（`output_schema=ContinuityReview`），world/character generate 各触发 `WorldGenResult`/`CharacterGenResult`——单一固定-parsed 的 `FakeReviewGateway` 不够。端点测试自带 `_SchemaRoutingGateway({Schema: instance})`（`run` 据 `req.output_schema` 路由，未命中返 None），三个网关 dep（worldbuilder/character_gen/precheck）override 成同一个。**生成预览端点也要 `commit()`**（网关 ledger add-only → 不 commit 则 usage 静默丢，同 draft 坑）——断言 `session.commits==1` 即便预览不写业务表；冲突 409 路径同样 commit 落 precheck usage。
- [2026-06-19] @llm **回退/熔断要让适配器把瞬时故障翻译成 `TransientProviderError`，否则网关只重试/回退它认得的错**（T5.4）：`Gateway` 的重试谓词 `_is_retryable` 只认 `TransientProviderError` 和 `AppError(RATE_LIMITED)`；普通 `Exception`/厂商原生异常**不重试不回退、直接上抛**（内容策略拒绝等本就该让作者知情，§4.5）。故每个适配器在 `complete`/`stream` 的 `except` 里按异常类名(RateLimitError/APITimeoutError/APIConnectionError/InternalServerError/APIStatusError)+`status_code`(429 或 ≥500) 判定瞬时→包成 `TransientProviderError(provider=...)`。**新增适配器必须照做**，否则它的 429/5xx 会穿透回退链变成硬失败。`packages/llm_gateway/tests/fakes_resilience.py` 的 `ScriptedAdapter(failures=[...])` 用 `TransientProviderError` 模拟可回退失败、用 `AppError(其它码)` 模拟不可回退。
- [2026-06-19] @llm **`Gateway` 构造签名扩了但保留 `resolver=` 兼容——apps/api 的 `build_gateway_for_tier` 无需改即不破，但也因此默认不启用回退链**（T5.4）：`Gateway(adapters, ledger, *, chain_resolver=None, resolver=None, max_retries=2, breaker=None)`。`resolver`(单路由) 会被自动包成单元素链，故 `project_deps.build_gateway_for_tier` 仍传 `resolver=resolve_route` 照常工作（**未回归**）。但单元素链=无回退——**真正启用回退须 apps/api 改 `build_gateway_for_tier`**：按 DB `tier_routing.fallback`(`StoredRouting.fallback` 已有) 预备多个 provider 适配器进 `adapters` dict + 传 `chain_resolver=lambda tier: chain_from_routing(tier, primary, fallback)`，并可注入跨请求共享的 `CircuitBreaker`(默认每个 Gateway 实例自带一个、进程内不共享，逐请求新建网关则熔断不跨请求累积)。Anthropic/Gemini 适配器真实接线还需 @devops 加 `anthropic`/`google-genai` SDK 依赖(适配器本身懒导入/注入客户端，仅 apps/api 构造真实 client 时需要)。
- [2026-06-19] @llm **character-gen schema 的 `traits`/`speech_tics` 是 `list[str]`，但 DB `characters.traits`/`speech_tics`/`arc` 列是 JSONB **dict**（T5.2 ingest 须转形）**（T5.1）：C6 扩 `CharacterCard` 把 `traits`/`speech_tics` 设计成 `list[str]`（生成产物天然是列表）、`arc` 设计成 `str`（弧光一句话），但 `packages/db/ww_db/models.py` 的列类型是 `traits: JSONB dict` / `arc: JSONB dict` / `speech_tics: JSONB dict`，而 `tags`/`relations` 才是 JSONB **list**。T5.2 入库映射时：list 字段（tags/relations）直落；`traits`/`speech_tics` 须包成 dict（如 `{"items":[...]}` 或按语义拆）或由 @db 调列形；`arc`(str) 同理包 dict。**别假设 schema 字段形 == DB 列形**——schema 贴生成产物、DB 列贴存储，ingest 是转换层。`role`/`backstory` 是 Text 列、直落。`WorldEntityCard.rules:list[str]` ↔ `world_entities.rules` JSONB（包 `{"rules":[...]}` 或裸 list）。
- [2026-06-19] @llm **入库前 continuity 校验是「编排器追加的一道检查」，复用 `continuity_spec` 而非新建 spec**（T5.1，ARCH §6.5）：`precheck_generated_cards` 传入 `continuity_spec`（不是 character-gen 自调、不是新审种），跑生成角色卡 vs 世界观/已有角色真相源、返 `list[Conflict]`。守不变量#1（agent 只经 DB/编排器通信、不互调）。T5.2 入库端点拿 conflicts 做 gate：有冲突→提示作者裁决/调整，**不静默入库**（同 accept 的冲突 gate 精神，但这是生成预览阶段、非 chapter accept）。独立生成语义：网关失败直接上抛端点处理（不做 review 图的失败隔离）。
- [2026-06-19] @qa **学文风 E2E：后台 work 自造网关须 monkeypatch `style.build_gateway_for_tier`（不是 override 网关 dep）**（T4.5）：`POST /style` 请求阶段的凭据探测走 `get_style_extract_gateway`（可 `dependency_overrides` 注假网关绕过 503），但**真正提取**在 `run_job` 自建的独立 session 上跑 `_make_style_learn_work`→其内 `build_gateway_for_tier(session, store, "analyst")` 从凭据建**真 OpenAI 适配器**（与请求 dep 无关）。E2E 无真凭据 → 必 `monkeypatch.setattr(ww_api.routers.style, "build_gateway_for_tier", 返真 Gateway 包假适配器)`（ledger 绑入参 session=后台真 session）。写侧 `SqlStyleFingerprintWriteRepo` + `run_job` 默认 `repo_factory`→真 `SqlJobRepo` **保持不动** → 指纹/job 真落 pg（只换 LLM）。同 M3：override `get_session_factory`→`e2e_sm`（同测试 engine/loop），ASGITransport 下 `await client.post` 等 background task 跑完，轮询 `GET /jobs/{id}` 稳定见 done。第四审 E2E：只 override `get_review_gateway`（analyst）即覆盖整个四审图（四档位同 deepseek 单适配器），假适配器据 `req.output_schema is StyleDriftReview` 返漂移 parsed；回炉假适配器据 `req.output_schema is None` 返改写串（writer 纯文本，保证 refined≠original）。
- [2026-06-19] @frontend **`GET /jobs/{id}` 在 OpenAPI 是裸 `{[k]:unknown}`（T0.3 未建命名 schema）**（T4.4）：前端无法直接用生成类型当 JobView，须在 `lib/jobs/job.ts` 安全收窄（`narrowJob`：status 非法→`queued`、progress 夹取 0..100、result 仅取 object 非数组）。同理 `chapter_reviews.style` 是松散 `{[k]:unknown}|null`→`normalizeStyleDrift` 收窄成 `{score(缺省100),segments:[{idx,score(缺省100),label:string|null}]}`（score 缺省 100 = 后端无指纹降级态）。轮询 reducer/收窄是纯逻辑、node-env 单测；hook 只管 setTimeout+fetch。
- [2026-06-19] @frontend **`useJobPoll` 的 `initialPollState.status` 默认 `"polling"`，调用方别在挂载即据此显进度**（T4.4）：`useStyleLearn` 用 `startedRef` 守门——只在提交过一次 `POST /style` 后才把 `poll.status` 映射进 UI，否则文风页一进就误显「提取中…」。轮询「停止」靠 reducer 到 done/failed 终态后不再 `setTimeout`（无 abort 概念，区别于 SSE 的 AbortController）。回炉 `useRefine` 503(`error.code==="LLM_UNAVAILABLE"`)→提示去设置，仿 review 流前 503 检出。
- [2026-06-19] @backend **测 `run_job` 后台路径要 monkeypatch `job_runner.SqlJobRepo` 而非 `_default_repo_factory`**（T4.3）：`run_job(..., repo_factory=_default_repo_factory)` 的 `repo_factory` 是**默认参数值**，在函数**定义时**绑定——`monkeypatch.setattr(runner_mod, "_default_repo_factory", ...)` 改的是模块属性、改不到已绑定的默认值，无效。`_default_repo_factory` 体内 `return SqlJobRepo(session)` 引用的是**模块全局** `SqlJobRepo`，故 monkeypatch `runner_mod.SqlJobRepo` 才生效（否则后台 `set_running` 用 `SqlJobRepo(FakeSession)` 触 `.execute` 炸、被 run_job 吞成 job_failed）。端点测 BackgroundTask 落库路径：override `get_session_factory`→`FakeSessionFactory` + monkeypatch `style.build_gateway_for_tier`(产 StyleFingerprintResult)/`style.SqlStyleFingerprintWriteRepo`(指共享 fake repo) + `runner.SqlJobRepo`(指 fake job repo)；ASGITransport 下 `await client.post` 会等 background task 跑完，断言稳定（同 run_overdue_scan 时序先例）。
- [2026-06-19] @backend **bulk `UPDATE` 取受影响行数要 `cast` 成 `CursorResult`**（T4.1 `reap_zombies`）：`await session.execute(update(...))` 静态类型是 `Result[Any]`，**无 `.rowcount`** 属性（mypy strict 报 `attr-defined`）；实际运行时是 `CursorResult`。做法：`from sqlalchemy import CursorResult` + `cast("CursorResult[Any]", result).rowcount`。`reap_zombies` 用一条 bulk UPDATE（非逐行）标 `running→failed` 高效且原子。
- [2026-06-19] @backend **`run_job` 失败置态要开「全新」session**（T4.1）：`work` 抛异常后，原 session 的事务已作废，不能复用它 `fail(job_id, ...)`——`_mark_failed` 经 `session_factory()` 再开一个独立 session 写 failed + commit。故 `run_job` 失败路会开 **2 个** session（业务一个 + 失败置态一个），单测断言 `len(factory.sessions)==2`。`run_job` 对 repo 只依赖最小 `JobLifecycleRepo` Protocol（set_running/complete/fail）——比全 `JobRepo` 窄，单测 fake 不必实现 create/get/reap（同 `run_overdue_scan` 用最小 `OverdueScanRepo` 先例）。
- [2026-06-18] @llm **`SqlAlchemyLedgerSink.record` 改 add-only（T3.8 修，去 `await flush()`）**：原 add+flush 在并行审里 flush 重入炸（见下条）。改为只 `session.add(row)`——`add()` 同步不让步→并行协程不交错；持久化靠端点/事务 `commit()`（自动 flush）。draft/review/accept/outline 端点末尾均已 commit，记账不丢。**凡新增「并行用网关产 usage」的路径，sink 必须保持 add-only，不得在并行段 `await flush`。** 旧 gotcha「record 只 flush 不 commit」措辞已过时——现为「add-only，commit 归调用方」（commit 仍会 flush，故记账语义不变）。
- [2026-06-18] @qa/@llm **并行审记账撞 session（M3 真 bug，T3.7 暴露→T3.8 修）**：三审同 LangGraph superstep 并行，各自 `gateway.run()`→共用**请求 session** 的 `SqlAlchemyLedgerSink.record`(`session.add`+`await session.flush()`)。`AsyncSession` 非并发安全→第二/三审 flush 撞 `Session is already flushing`→被 `run_review` 失败隔离吞成 `incomplete`→**foreshadow/pace 静默丢失**（SSE 无事件、`chapter_reviews.foreshadow_sug`/`pace` 列空、日志 `review_node_incomplete error='Session is already flushing'`）。M2 单审未触发。根因=并行路径里有 `await flush()`（唯一 await 的 DB-IO）。修向：审稿期记账避免并发 flush（add-only 靠端点 commit / 或缓冲后 collect 串行落 / 或并行审记账用独立 session）。
- [2026-06-18] @qa **E2E 验「验收后 BackgroundTask 扫描」时序**：httpx `ASGITransport` 下 `await client.post(...)` 会等 ASGI app 协程（含 Starlette background tasks）跑完才返回，故 client 上下文退出后断言稳定不 flaky；必 override `get_session_factory`→`e2e_sm`（真 sessionmaker，同测试 engine/loop），否则默认 `get_sessionmaker()` 另建 engine 绑别的 loop。
- [2026-06-18] @frontend **审稿 seed 扩成三态**：`useReviewStream.seed` 入参由 `ReviewConflict[]` 改 `ReviewSeed{conflicts,foreshadow,pace}`（进页同时种伏笔建议/节奏留痕，免重审即可看）。`ReviewStreamState` 加 `foreshadow:ForeshadowSuggestion[]`(累加) / `pace:PaceReport|null`(**替换非累加**，对齐后端 collect「pace 整 dict 入列」)。
- [2026-06-18] @frontend **伏笔 transition 乐观更新只改 status、回滚存快照**：`useForeshadow.transition` 本地先改 status，PATCH 失败 `setItems(snapshot)` 回滚 + 读 `error.details.reason`(duplicate/invalid_transition/empty_update) 映射文案。register **不乐观**（要服务端 code 唯一校验）：成功才追加返回行，重复 code→422 友好提示不改 items。大纲页**无 GET 端点**：进页 `initialChapters=[]`，靠 `POST .../outline` 生成填充（无凭据→503 引导去设置）。
- [2026-06-18] @backend **端点级测「无凭据→LLM_UNAVAILABLE」不要靠真实 `get_*_gateway` dep 解析**——`FakeSession` 不支持 `.execute`，`SqlCredentialStore` 会炸成 500。做法：override 网关 dep 注一个 `raise AppError(LLM_UNAVAILABLE)` 的 async 函数（等价无凭据行为），与 review/accept/outline 测试一致。
- [2026-06-18] @backend **BackgroundTask 必须自建独立 session**：FastAPI BackgroundTasks 在 response 发回、请求 session 关闭后才跑——复用 `Depends(get_session)` 的 session 已关闭会炸。验收后到期扫描经可注入 `SessionFactory`(=`get_sessionmaker()`)，`run_overdue_scan` 内 `async with factory()` 开新 session 自己 commit；再加 `repo_factory` 缝便于单测注 fake、纯函数 await 不起后台线程。**accept 端点新增 `get_session_factory` 依赖→所有 accept 测试 client 需 override 它**（否则 dep 解析会建真 engine）。
- [2026-06-18] @backend **伏笔登记重复 code / 非法转移 → `VALIDATION`(422) 非 409**：现有唯一 409 码 `CONFLICT_UNRESOLVED` 专指未决冲突禁验收，不复用；`details.reason` ∈ `duplicate`/`invalid_transition`/`empty_update` 供前端区分。register 捕 SQLAlchemy `IntegrityError`→rollback→`AppError(VALIDATION)`；transition 捕 `InvalidTransition`/`LookupError`(404)。
- [2026-06-18] @llm **三审列类型不齐**：`chapter_reviews.foreshadow_sug` 是 JSONB **list**、`pace` 是 JSONB **dict**——collect 把 `ForeshadowReview{planted,resolved}`(dict) **扁平成单 list**、每条加 `kind:"planted"|"resolved"`；`PaceReview` 整体入 dict 列。贴合既有 DB 列类型、不改 @db。新审种加入须同步：collect 列映射 + `sse._section_result_events` 分支 + `normalize_review` 键名。`sse.py` import `.collect` 的 spec.name 常量做 section 分流（无循环：collect 不 import sse）。
- [2026-06-18] @llm **三审并行图测试按 `req.output_schema` 路由 parsed**（`SchemaRoutingRunGateway`）——单 `FakeRunGateway` 对所有审返同一 parsed 会让三审拿错 schema。验失败隔离：某 schema 不登记→网关抛 KeyError→`run_review` 隔离为 `incomplete`，无需改 gateway。
- [2026-06-18] @backend **伏笔 `record_progress` append JSONB 必须新建 list 重赋值**（`row.progress = [*old, entry]`），不可原地 `.append()`——SQLAlchemy 默认不侦测可变 JSONB 原地突变，原地改不脏标记→flush 丢失。`scan_overdue` 仅在有变更时 flush（空扫描零写）。状态机：`transition` 同态(current==to)幂等放行、CLOSED 为终态(离开 CLOSED 全非法抛 `InvalidTransition`)；`is_overdue` 严格大于(current==expected_close_to 仍在窗口内不逾期)、无 expected_close_to 永不逾期。
- [2026-06-18] @llm **orchestrator 内每模块各自声明 `GatewayRun` Protocol**（`review_node.py` 与 `outline_node.py` 各一份，按模块最小依赖）——**不跨模块复用、不在 orchestrator `__init__` 重复导出**（`__init__` 只导出 review_node 那个，避免 re-export 名冲突）；outline 的为模块内部用。
- [2026-06-21] @llm **【撤销上条】`GatewayRun` 已收敛为单点定义**（`orchestrator/_protocols.py`，仅依赖 `ww_llm_gateway.types` 无环）→ review/generation/outline/style_extract 4 节点 + graph/`__init__` 统一 `from ._protocols import GatewayRun`，删除 4 处重复声明（CODE_REVIEW P2 DRY）。上条「每模块各自声明、不复用」的理由（怕环/re-export 冲突）不成立——单点 Protocol 无环、`__init__` 单点导出无冲突。
- [2026-06-18] @qa **M2 E2E 多档位假适配器**：`config.tier_defaults` writer/analyst/light 默认同 provider(deepseek)→单个假适配器(`provider="deepseek"`)即覆盖三档位；据 `req.output_schema is ContinuityReview`(续审)/否则 digest facts schema 分支返回 `parsed`；三档位用不同 `input_tokens` 区分以断言各自落 `usage_ledger`。三端点记账闭环 = review 端点流末 commit + accept 验收事务末 commit 都把网关 ledger flush 真正提交（M1 ledger bug 在 M2 无复发）。
- [2026-06-18] @qa **E2E 验证「digest 从终稿非草稿」(#4) 手法**：final_text 注入草稿没有的标记串，假 light 适配器把它放进 digest facts 的 `summary`，断言 `chapter_digests.facts["summary"]==标记` 且 `标记 not in draft_text`。accept 409 gate 经 ASGITransport 正常返回（`AppError` 不上抛），断言 `resp.json()["error"]["details"]["missing_conflict_indices"]`（`ErrorCode` StrEnum → `"CONFLICT_UNRESOLVED"`）。
- [2026-06-18] @frontend **审稿历史 `conflicts` 在 OpenAPI 被标松散 `{[k]:unknown}[]`**（后端用 dict/JSONB 列）→ 前端 `lib/review/history.ts` 安全收窄成 `ReviewConflict{type,where,refs,suggestion}`，缺字段给默认、**保序**（顺序=冲突 gate 的 `conflict_index` 身份，不可重排，否则裁决错位）。
- [2026-06-18] @frontend **审稿页重审完成重置裁决草稿用 streaming→done 边沿判定**（`wasReviewingRef`）：不能用 conflicts 长度变化判（同数不同组会漏重置），也不能在 seed（phase=idle，进页种历史留痕）时误触发。
- [2026-06-18] @backend **冲突 gate 判据（accept）**：冲突身份 = 「最近一条 `chapter_reviews.conflicts` 列表的下标」；裁决 `ConflictDecision{conflict_index, verdict:accept|ignore|manual, note?}`；gate 通过 = 裁决的 `conflict_index` 集合**覆盖** `range(len(conflicts))`，缺判→409 `CONFLICT_UNRESOLVED` + `details.missing_conflict_indices`/`conflict_count`；无审稿留痕或零冲突→直通验收。
- [2026-06-18] @backend/@qa **ASGITransport 默认 `raise_app_exceptions=True`**：accept 事务回滚测试里，repo 抛的非-`AppError`（如 `RuntimeError`）会**上抛到 client 调用方**而非返回 500——测试用 `pytest.raises(RuntimeError)` 包住请求调用、再断言 `session.commits == 0`（证明未部分提交）。DB 级原子回滚由 T2.7 真 pg 覆盖。
- [2026-06-18] @llm **langgraph 并行节点同写一个 state key 必须配 reducer**：并行四审同 superstep 各写 `{spec.name: ...}` 到 `reviews`，无 reducer → LangGraph 抛 `InvalidUpdateError`。用 `Annotated[dict, merge_reviews]`（浅合并、返回新 dict、不可变）。`ChapterState` 因逐节点填充改 `total=False`。
- [2026-06-18] @llm **langgraph `add_node` 重载拒收显式 `Callable` 类型别名**：`make_review_node` 返回的具名 `BoundReviewNode` 别名会让 mypy 报 incompatible arg-type。图工厂里改用 **inline `async def` 闭包**（spec 经默认参 `_spec=spec` 绑定，避开循环晚绑定），mypy 才推出精确函数类型匹配重载。`make_review_node` 仍作公共缝（供 T2.5 跑单审）单独保留+单测。
- [2026-06-18] @llm **审稿失败隔离两层**：审级失败在 `run_review` 内标 `incomplete`（§5.2 任一审不阻塞其余）；归一级意外（畸形 entry）→ `normalize_review` 发一条 `error` 事件后收尾（同 `normalize_deltas` 纪律）。
- [2026-06-18] @llm **instructor 1.15.3 结构化输出接线**：用 `AsyncInstructor.create_with_completion(messages=..., response_model=..., model=..., max_tokens=...)` 同时拿 `(parsed, raw_completion)`——`raw.usage` 用于记账，避免结构化路径丢 usage；usage 提取统一走 `_usage_from(raw_usage)`（文本/结构化/流共用）。adapter 经可注入 `StructuredClient` Protocol 注 fake（测试不联网）。结构化路径 `ProviderResult.text = parsed.model_dump_json()`（日志/留痕），消费走 `parsed`。**带 `output_schema` 时 `gateway.run(req).parsed` 必非 None**。
- [2026-06-18] @backend **frozen View 的运行时不可变断言因类型分两种**：dataclass frozen(`ChapterView`)→赋值抛 `FrozenInstanceError` 且 **mypy 会静态报错**（测试里故意赋值需 `# type: ignore[misc]`）；Pydantic frozen(`DigestView`/`ReviewView`)→抛 `ValidationError` 但 **mypy 不静态校验**（**勿加** `# type: ignore`，否则被判 unused-ignore，ruff/mypy 红）。
- [2026-06-18] @backend **M2 验收-side repos 只 flush 不 commit**：`promote_to_accepted`/`digest.append`/`review.record`/`set_decisions` 均只 `flush()`，交由 T2.4 验收事务单次 `commit()`（对齐「写库副作用在事务/编排层」）；唯独 draft `save_draft` 仍自 commit（M1 自动保存语义）。`(project_id,chapter_no,version)` 唯一性是 **DB 级**(T0.2 models 定义)，纯 fake 单测不断言它（不引 pg 依赖以免 pytest 门禁需起库）→ 由 T2.7 E2E 真实 DB 覆盖。
- [2026-06-18] @backend/@llm **网关 ledger 只 flush、调用方必须 commit**（不变量「写库副作用在编排层不在网关」的代价）：`SqlAlchemyLedgerSink.record` 只 `flush()` 不 `commit()`；`get_session` 退出时不提交 → 隐式回滚。draft SSE 端点曾因此把 `usage_ledger` 行丢掉（T1.9 暴露）。修复：端点在 SSE 流**耗尽后** `await session.commit()`（FastAPI 缓存 `Depends(get_session)`，网关 ledger 与端点同一 session）。**M2 起凡用网关产 usage 的路径（四审/accept）都要确保所在事务最终提交**，否则记账静默丢失。
- [2026-06-18] @frontend **Next 里消费 SSE 用 `fetch`+`ReadableStream` reader，不用 `EventSource`**（EventSource 不能 POST、不能干净 abort）。"停"=`AbortController.abort()`，吞掉 `AbortError`、已收 token 留在 state 并被自动保存。流前错误（无凭据→503 `LLM_UNAVAILABLE` 是 **JSON 信封非帧**）经 `!res.ok` 检出、从 `{error:{code,message}}` 解析。
- [2026-06-18] @frontend apps/web 测试环境坑：`server-only` 包未装→别 import（Server Component 仅靠约定）；vitest 是 **2.x**（无 `toHaveBeenCalledExactlyOnceWith`，用 `toHaveBeenCalledTimes`+`toHaveBeenCalledWith`）；**未装 jsdom/testing-library**→单测走 node env 测纯逻辑（SSE reducer/帧缓冲、debounce、向导状态机），组件 DOM 渲染留给 T1.9 Playwright。
- [2026-06-18] @backend **stub user 未 seed → FK 风险**：`projects.owner_id` / `usage_ledger.owner_id` / `provider_credentials.owner_id` 全 FK→`users.id`，但仓库无 seeded stub user。约定 `STUB_OWNER_ID = uuid.UUID(int=1)`（对齐网关 `Scope.user_id` stub）。任何写这些表的路径（写章记账、立项、存凭据）跑前必须存在该 user 行——T1.4 幂等 seed（startup/lifespan）；auth 落地后替换为真实 principal。
- [2026-06-18] @backend 含 nullable 列的唯一约束别用 PG `ON CONFLICT`：`provider_credentials(owner_id,project_id,provider)` / `tier_routing(project_id,tier)` 的 `project_id` 可空，PG 默认 NULLS DISTINCT → 全局行(`project_id=NULL`)的 `ON CONFLICT` 不去重、会插重复。T1.7 用显式 read-modify-write(`project_id IS NULL`)。若 @db 后续给约束加 `NULLS NOT DISTINCT` 可改回原生 upsert。
- [2026-06-18] @llm langgraph **1.2.5** 实装（pyproject 写 `>=0.2.40` 但装了 1.x，用 1.x API）：`from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver` → `AsyncPostgresSaver.from_conn_string(...)`(async ctx) → `await saver.setup()`（只在 migrations/CI）。mypy strict 下：`add_node` 不收 `functools.partial`（用 `async def` 闭包绑定依赖）；`StateGraph[...]`/`CompiledStateGraph[...]`/`BaseCheckpointSaver[Any]` 需写全类型参；测试 state dict 标注 `: ChapterState`、`ainvoke` config 标注 `: RunnableConfig`。
- [2026-06-18] @orchestrator **跨包测试同名碰撞**：每包 `tests/` 无 __init__（避免与顶层 `tests` 包撞），但多包并存时 ① pytest 全跑：同名顶层模块 `fakes.py` 撞（"import file mismatch"）→ 测试替身用**全局唯一**名（`fakes_gateway`/`fakes_providers`/`fakes_orchestrator`）；② 聚合 `mypy packages apps`：多个 rootless `conftest.py` 撞成同名模块 → root pyproject `[tool.mypy] exclude=["(^|/)conftest\\.py$"]`（conftest 仅 fixtures，按包仍受检）。test_*.py 保持全局唯一名。
- [2026-06-18] @backend Fernet 凭据 key 取 `settings.credential_enc_key`(env `CREDENTIAL_ENC_KEY`)；`get_settings` 是 lru_cache，测试改 env 后须 `get_settings.cache_clear()`（在 client fixture 里）。list/GET 不解密（只回脱敏占位），仅 probe 按需解密——缩小明文暴露面。
- [2026-06-18] @orchestrator 后台 fork 子代理会因 "stream idle timeout" 早夭（T1.1 网关 fork 跑了 8.5 分钟、0 产出、0 文件）。坑：别盲等/盲重启重型 fork。做法：fork 完成后先核对**产物文件 + 自跑门禁**再认其结果；早夭则编排者内联实现该任务（已有完整上下文），不再二次 fork 同一关键路径任务。
- [2026-06-18] @backend 记忆选择走**确定性子串名匹配**（在 flatten+sorted 的 beats/facts 文本上 + 显式 entities 列表），非 pg_trgm/向量；§3.4 的 pg_trgm/作者 pin 兜底属后续。`selection` 与 `render_cards` 均按 `(kind, name)` 排序→输出与 repo 返回顺序无关；CJK 名按 **codepoint** 排（乙 U+4E59 < 甲 U+7532），测试断言按 codepoint 而非甲乙丙语义。`latest_state` 只进 `volatile`（卡片），`stable_core` 故意不含它以保缓存前缀字节稳定。
- [2026-06-18] @qa/@llm 包内单测放 `packages/<pkg>/tests/`（**无 __init__.py**，避免与顶层 `tests` 包同名冲突）；测试替身放独立 `fakes.py` 用绝对导入 `from fakes import ...`（不能 `from .conftest import`，相对导入在无包目录下报 no known parent package）；`conftest.py` 只放 fixtures。门禁按包跑：`uv run {ruff check|mypy|pytest} packages/<pkg>`。
- [2026-06-18] @frontend pnpm 11 配置已迁出 package.json/.npmrc → 只读 `apps/web/pnpm-workspace.yaml`。坑：`pnpm run <script>` 前会跑 verifyDepsBeforeRun 触发隐式 install，遇 `ERR_PNPM_IGNORED_BUILDS`(esbuild/sharp/unrs-resolver 被默认拦截)直接整条命令失败。做法：在 `pnpm-workspace.yaml` 写 `onlyBuiltDependencies:` 白名单 + `verifyDepsBeforeRun: false`。
- [2026-06-18] @frontend gen:api 离线管线：`scripts/gen-api.mjs` 用 `execFileSync(uv, [...])` 取后端 OpenAPI(不靠运行中的服务) + 直接调 `node_modules/.bin/openapi-typescript`(别用 `pnpm exec`，会再触发 deps 检查)。改后端 schema 后跑 `pnpm gen:api` 重生成 `lib/api/schema.d.ts`。
- [2026-06-18] @backend async engine 跨事件循环坑：`get_sessionmaker` 用 `lru_cache`，engine 绑定首个 loop；pytest-asyncio 每测试新 loop → 复用会报 `Connection._cancel never awaited`/连接失败。测试里每个 DB 测试 `get_sessionmaker.cache_clear()` 在当前 loop 重建并 `dispose()`。
- [2026-06-18] @db mypy strict + 跨包 editable 安装：模型里 `from ww_db.base import Base` 会被判成 Any(报 "cannot subclass Any")，需在根 `pyproject.toml` 设 `[tool.mypy] mypy_path=[...各包源码目录...]` + `namespace_packages=true`，否则 editable 包解析不到源码。
- [2026-06-17] @docs 命名契约：后端 Python/Pydantic 一律 **snake_case**（字段/schema/JSON），前端经 OpenAPI 生成类型消费——别手写 camelCase 共享类型（评审里曾因 TS 旧栈遗留 camelCase 与 Pydantic 契约对不上）。
- [2026-06-17] @docs 数据写入只走**验收事务**：四审 agent 只读不写；任何 AI 产出入库必经 `accept`（HITL gate）。别在 agent 节点里直接写库。
- [2026-06-19] @qa M5 E2E：生成端点的网关（`get_worldbuilder_gateway`/`get_character_gen_gateway`/`get_precheck_gateway`）是**请求 scope FastAPI 依赖**（内部虽调 `build_gateway_for_tier`，但作为 `Depends` 暴露）→ E2E 直接 `app.dependency_overrides[get_*_gateway]=真Gateway包假适配器` 即可，**无需** monkeypatch `build_gateway_for_tier`（区别于 M4 学文风：那是 `run_job` 后台**自建**网关，须 monkeypatch 模块级 `style.build_gateway_for_tier`）。
- [2026-06-19] @qa `served_by`(fell_back/degraded) **不出 API**（仅观测/记账标注）→ 端到端 HTTP 路径要证「回退真发生」，断言 **DB 真源 `usage_ledger.provider` = fallback 名而非 primary**（网关记账记实际服务方，ARCH §4.5）。HTTP fallback 用真 `Gateway` + `chain_resolver=chain_from_routing(...)` + primary 假适配器每次 `complete` 抛 `TransientProviderError`（备 ≥max_retries+1 次失败耗尽重试）→ 切 fallback；能力降级路径（无支持者）经直测 `Gateway.run` 验 `served_by.degraded` 更清晰。
- [2026-06-19] @qa `ww_agents.Conflict.type` 是 `ConflictType` **Literal**（性格漂移/能力不符/设定违例/地理矛盾/时间线倒错）——构造 precheck 假冲突须用枚举值之一（如「设定违例」），随手写「设定冲突」会 pydantic literal_error。
- [2026-06-19] @llm ⚠️**已被下条推翻**（保留作纠错轨迹）：曾写 Kimi Code coding API 的伪造头「只发 `User-Agent: KimiCLI/1.5`、不带 X-Msh-*」——该结论源自分歧/错误参考 `picassio/pi-kimi-coder`（UA-only），导致误把头集裁成 UA-only。**勿采纳本条**，见下条。
- [2026-06-19] @backend Kimi Code device authorization **必须带 `scope=kimi-code`**（`start_device_authorization` 请求体 = `{"client_id": <id>, "scope": "kimi-code"}`）。K1.5 联网实测：省略 scope 登录能拿到真 JWT，但调 `api.kimi.com/coding/v1` 回 `401 Invalid Authentication`——token 缺 coding entitlement。`scope=kimi-code` 是 coding-agent 文档化 scope（`ooojustin/opencode-kimi` `constants.ts` 发送；kimi-cli v1.41.0 已不发但服务端仍接受）。scope **只**放 device_authorization；token 交换（`poll_token`）/刷新（`refresh`）**不带** scope（device flow 惯例）。⚠️ 此前 K1.3 单测/decision/contract 写「省略 scope」（误信 kimi-cli v1.41.0），已全部修正。
- [2026-06-19] @llm **Kimi Code coding API 伪造头真源 = `ooojustin/opencode-kimi`（`src/headers.ts`+`src/constants.ts`，1:1 镜像 kimi-cli v1.37.0），发完整 7 头、UA `KimiCLI/1.37.0`**（K1.2 校正，推翻上方 pi-kimi-coder UA-only 那条）：`picassio/pi-kimi-coder` 是分歧/错误参考（只发 UA），照它裁成 UA-only 是误修，已回退。`kimi_code_headers()` 发 `User-Agent=KimiCLI/1.37.0` + `X-Msh-Platform=kimi_cli`（字面常量，非 OS 名）+ `X-Msh-Version=1.37.0`（==UA 版本）+ `X-Msh-Device-Name`（`socket.gethostname()` ASCII 化）+ `X-Msh-Device-Model`（macOS=`f"macOS {platform.mac_ver()[0]} {platform.machine()}"`，`machine()` 原样 `arm64`/`x86_64` 不归一化）+ `X-Msh-Os-Version`（`platform.version()`）+ `X-Msh-Device-Id`（稳定 32 位无连字符 `uuid4().hex`：env `KIMI_DEVICE_ID` 优先→否则读/建 `~/.kimi/device_id` 复用，**绝不每次随机**，否则 Kimi 侧每次当新设备）。源码若与此有出入以源码为准（本次核对 master 一致）。⚠️ HTTP 头值含非 ASCII 会被底层 fetch/httpx 拒，host 派生值（Device-Name/Model/Os-Version）须 ASCII 化（`asciiHeaderValue`：裁 `\x20-\x7e` 之外 + trim，空回退 `unknown`）。单测用 env `KIMI_DEVICE_ID` 注入固定 id 保 CI 确定（不触真 ~/.kimi）。ruff E501 按显示宽度算（中文占 2 列），含中文 docstring/注释的行别贴边 100。
- [2026-06-19] @backend `assemble`（C5）此前只从 world_entities/characters/style/rules + cards/foreshadow/digests/outline.beats 组装，**从不含项目 premise/logline/theme/title，也无「写章」指令** → 全新项目（只有 premise、无世界观/角色/大纲）产出 `stable_core=""`+`volatile=""`，writer 的 user message 为空，LLM 回 `400 "the message at position 0 with role 'user' must not be empty"`。修复：`MemoryRepos` 加第 8 个 repo `project:ProjectSpecRepo`（`spec(project_id)->ProjectSpecView{title,logline?,premise?,theme?}`，按 project_id 读 `projects`，无 owner 过滤——owner 隔离归路由层）；`assemble` 把「作品蓝本」放进 `stable_core` 首段（书级 spec=定型→缓存前缀，不违 #9），并让 `volatile` 始终含 `请创作第 {chapter_no} 章的正文。`（易变指令）。⚠️ 任何手搓 `MemoryRepos(...)` 的地方（含测试 fake）现在**必须**补 `project=` 字段，否则 dataclass 缺参报错。无 DDL 变更（只读既有 projects 列），无迁移。
- [2026-06-22] @frontend/@orchestrator T6 创作工具箱通用端点的两处契约边界（加新生成器时注意）：① `ToolGenerateRequest` 是**固定字段并集** `{brief, chapter_no?, count?, kind?}`——`GeneratorRunner` 只映射落在此集合内的 `input_field` 名；新生成器若声明了集合外的 `input_field`，前端会**静默忽略**该字段。新工具的 `input_fields` 须落在 `brief/chapter_no/count/kind` 内，否则需先扩 `ToolGenerateRequest` 契约（+`pnpm gen:api`）。② `ToolInputFieldView.type` 是自由字符串，后端可声明 `"select"` 但 descriptor **无 `options` 字段** → 前端把 select 当 text 输入渲染。若某工具需要真下拉选项，须先给 `InputField`/`ToolInputFieldView` 加 `options` 再改前端 `GeneratorRunner`。
- [2026-06-23] @backend 多章链网关（C2）：**单档 `build_gateway_for_tier(tier)` 网关不能驱动跨档链**。其 `chain_resolver`/`_resolver` 闭包恒返该 tier 的链、**忽略入参 `req.tier`**，故拿它跑 review(analyst)/digest(light) 会全部错路由到 writer 档的 provider/model。链/任何跨档编排须用 `build_chain_gateway`（union 三档适配器 + `chain_resolver` 据 `req.tier` 分派）。测试用 mock 网关按 `req.output_schema` 路由（无 schema=write，有=review），不受此影响。
- [2026-06-23] @backend langgraph `CompiledStateGraph.ainvoke` 的 mypy 重载：`config` 必须是 `RunnableConfig`（`from langchain_core.runnables import RunnableConfig`），裸 `dict[str,dict[str,str]]` 不匹配任何重载 → call-overload 错。返回值是 `dict[str,Any] | Any`，传给取 `__interrupt__`/`written` 的 helper 前先 `dict(raw_final)` 收敛为 `dict[str,Any]`。interrupt 命中时返回值含 `__interrupt__`（Interrupt 对象列表），载荷读 `getattr(first,"value",first)`（兼容 dict）。
- [2026-06-23] @backend 改 `JobRepo` Protocol 加方法（如 `set_awaiting`）= 契约变更：**所有 fake 实现都要同步补**，否则 mypy 报 Protocol 缺成员（本次漏了 `packages/core/tests/test_job_repo.py::FakeJobRepo` + `apps/api/tests/fakes_projects.py::FakeJobRepo`）。grep `class Fake.*JobRepo` 全仓再补。
- [2026-06-24] @llm/@devops Prompt 外置：`prompts/*.md` 文件名按 **spec.name（连字符）** 非 Python 变量名——`style` agent 的 name 是 `"style"`→`style.md`（非 `style_drift`），另有 `character-gen.md`/`golden-finger.md`/`book-title.md`/`fine-outline.md`/`de-ai.md`。**改 prompt 内容后必须重生成金标准** `uv run python packages/agents/tests/_gen_golden.py`（覆盖 `tests/fixtures/prompt_hashes.json`），否则 `test_prompt_loader.py` 字节回归红；旧常量含反斜杠折行——外迁/比对一律用**运行时值**别用源码文本。`.gitattributes` 锁 `prompts/*.md text eol=lf`（路径含斜杠是**根锚定**，必须写完整嵌套路径 `packages/agents/ww_agents/prompts/*.md` 才匹配，裸 `prompts/*.md` 不生效）。
- [2026-06-24] @devops wheel 默认不打非-`.py` 数据文件——`prompts/*.md` 必须在 `packages/agents/pyproject.toml` 显式纳入（hatchling 用 `artifacts`，**别用 `force-include`**：hatchling 已含包目录下全部文件，force-include 会 duplicate-path 构建失败）；源码树 pytest **测不出**漏带（fail-fast 仅裸装时触发），靠 CI `agents-wheel-smoke`（build→裸装→`import ww_agents; assert ww_agents.SPECS`）守。`ww_agents` import 链拉起 `ww_llm_gateway`（`types.Tier`）→需 `structlog`，故 `packages/{llm_gateway,core}` 必须自声明 `structlog`（原靠 apps/api 传递，裸装 import 会 ModuleNotFoundError）。
- [2026-06-24] @backend `SpecResolver`：内置 name 走 `SPECS` 纯内存、**零 DB**；保留命名空间守卫在 **SkillRegistry 入库校验期**（非 resolver 读路径），用户 skill 与内置同名（`REVIEW_RESERVED_NAMES ∪ set(SPECS)`）→ `AppError(VALIDATION)`。name **精确字符串相等**：拼错近似名（`character_gen` vs `character-gen`）`output_schema_for` 返 None/不命中，是预期行为非 bug。
