Files
writer-work-flow/docs/design/prompt-management.md
Yaojia Wang dca4d45d4e refactor(agents): prompt 外置方案A — 21 prompt 散文外迁 .md + SpecResolver
把 21 个内置 agent 的 system_prompt 从 specs.py 的 Python 常量外置为
prompts/<spec.name>.md,import 期由 load_prompt 确定性加载;建立 SPECS 名册
+ SCHEMA_CATALOG(Pydantic 类型留 Python)+ 统一只读解析入口 SpecResolver。
纯重构、零功能/schema 变更,缓存断点前块字节级不变(不变量 #9)。

@llm packages/agents(步骤1-3)
- spec_model.py:抽出 AgentSpec(frozen,字段不变)
- prompt_loader.py:load_prompt = utf-8-sig 去BOM → LF 归一 → NFC → rstrip尾LF,
  内存缓存 + fail-fast(PromptNotFoundError),import 期确定性
- schema_catalog.py:SCHEMA_CATALOG[name]→output type 唯一真相源(refiner=None)
- prompts/*.md ×21:取常量「运行时值」程序化外迁(反斜杠折行已塌缩,
  物理换行≡运行时换行);文件名按 spec.name 连字符(style.md/character-gen.md 等)
- specs.py:删 21 常量 + AgentSpec 类;system_prompt=load_prompt(name)、
  output_schema=SCHEMA_CATALOG[name];建 SPECS + REVIEW_RESERVED_NAMES;
  *_spec 兼容期保留且 SPECS[name] is *_spec(同一实例)。804→337 行
- __init__.py:显式 __all__ 重导出(避 F401)

@backend packages/skills(步骤4-5)
- SpecResolver:内置 SPECS(纯内存、零 DB)+ 用户 SkillRegistry 统一 get;
  内置 name 永不触发 DB;output_schema_for 精确匹配
- skill_registry:保留命名空间守卫前移至入库校验,拒同名内置 → VALIDATION
- toolbox_registry:GeneratorTool.spec 改走 SPECS[...],删 12 个 *_spec 直接 import

@devops repo-root
- .gitattributes:prompts/*.md text eol=lf(修正:须用完整嵌套路径才匹配)
- packages/agents/pyproject:hatchling artifacts 纳入 prompts/*.md 随 wheel/sdist 分发
- ci.yml:新增 build wheel → 裸装 → import ww_agents.SPECS 冒烟

TDD 全程 mock 网关;门禁绿:ruff/format clean · mypy 209 files · pytest 744 passed
(含金标准 sha256 回归 / md↔spec↔catalog 一一对应 / fail-fast / BOM+NFC /
内置守卫 / 同一实例 / 编排器无回归 / apps/api import-smoke / 打包冒烟)
2026-06-24 04:49:44 +02:00

425 lines
43 KiB
Markdown
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Prompt 管理重构 · 方案A 终版实施方案v2已折叠评审
> **执行摘要**:把 21 个内置 agent 的 prompt 散文从 `specs.py` 的 Python 三引号常量外置为 `prompts/<spec.name>.md` 文件,用 import 期确定性加载器 `load_prompt` 注入;建立 `SPECS` 名册与 `SCHEMA_CATALOG`Pydantic 类型永留 Python新增 `SpecResolver` 统一内置/用户 skill 的只读解析入口。**核心约束**:缓存断点前块(不变量 #9字节级不变——金标准 fixture 必须取自 **AST literal_eval 的运行时字符串值**(而非源码文本),因为现有常量大量使用反斜杠折行,物理换行 ≠ 运行时换行。
>
> **预估工作量**34 人日(@llm 步骤13 约 1.5 日 · @backend 步骤45 约 1 日 · @qa 集成回归约 0.5 日 · @devops 打包配置 + `.gitattributes` + CI wheel 冒烟约 0.5 日)。纯重构、零功能变更、零 schema 变更(前端无需 `pnpm gen:api`)。
>
> **风险等级****中**。唯一真正的 CRITICAL 是缓存字节稳定性(反斜杠折行 + 尾换行 + BOM + Unicode 归一化四类漂移源),已在本版用「运行时值金标准 + 文件层契约 + CI 防护」三道锁覆盖。其余为可控的协作/打包/接线问题。
> 文档类型:架构设计(**只设计、不实现**)· 语言:中文 · 适用包:`packages/agents`@llm、`packages/skills`@backend、`packages/core/orchestrator`@llm本波不改、repo-root config@devops
> 上游锚点:`ARCHITECTURE.md §5.1`AgentSpec/ `§5.6`Skill registry/ `§4.6`(缓存断点);不变量 #2/#3/#9。
> 既定方向(不推翻,仅细化):① prompt 散文外置 `.md`spec 声明留 Python`system_prompt = load_prompt(name)`import 时读盘 + 内存缓存 + 确定性 + fail-fast② 集中注册表 `SPECS: dict[name, AgentSpec]`;③ 统一 `SpecResolver.get(name)` 内置与用户 skill 同路径,`output_schema` 这类 Pydantic 类型永远留 Python按 name 查 `SCHEMA_CATALOG`。
---
## 0. 现状基线(重构前事实,已逐字复核)
- `specs.py` 共 804 行 = `AgentSpec`3953+ 21 个 `*_SYSTEM_PROMPT` 多行字符串常量 + 21 个 `*_spec` 实例。
- **CRITICAL 事实(决定整套字节稳定设计)**21 个 `*_SYSTEM_PROMPT` 常量**大量使用反斜杠行延续**(行尾 `\`)。已逐字核验 `CONTINUITY_SYSTEM_PROMPT`行5685源码看似多行`\<newline>` 在 Python 解析时塌缩为空——**源码里的物理换行 ≠ 运行时字符串里的换行**。所有常量**无 f-string / `.format` / 占位符**(纯静态文本,已核验)。
- 所有 21 个 spec`input_schema=None`(入参统一为 assemble 后的序列化文本,无结构化契约);`scope="builtin"``output_schema` 为真 Pydantic 类20 个有,`refiner` 唯一 `None`)。
- 易错命名(已核验):`style_drift_spec`(变量名)的 `name="style"`行270271`"style_drift"`);文件须按 **spec.name** 命名 → `style.md``character-gen.md``golden-finger.md` 等连字符名。
- **完整消费方矩阵6 个直接 import 点,跨 @llm / @backend 两个 owner** —— 评审 HIGH 修正,原稿遗漏 3 处 apps/api 路由:
| # | 文件 | import 的 spec | owner | 本波处理 |
|---|---|---|---|---|
| 1 | `packages/core/.../orchestrator/graph.py` `REVIEW_SPECS`(四审硬编码元组) | continuity/foreshadow/style/pace | @llm | **不改**(兼容期复用同一实例) |
| 2 | `packages/core/.../orchestrator/generation_node.py`§6.5 入库前 continuity 校验) | `continuity_spec` | @llm | **不改** |
| 3 | `packages/core/.../orchestrator/chain/graph.py``review_specs` 默认参数 `from ..graph import REVIEW_SPECS` | 四审 | @llm | **不改** |
| 4 | `packages/skills/.../toolbox_registry.py``GeneratorTool.spec` | 8 个生成器 `*_spec` | @backend | **改**:走 `SPECS["<name>"]` |
| 5 | `apps/api/.../routers/toolbox.py:22` | `continuity_spec`world-entity 入库 precheck | @backend | **本波保留 `*_spec`**(兼容期不动),后续波次再切 |
| 6 | `apps/api/.../routers/outline.py:23` + `style.py:25` | `outliner_spec` / `refiner_spec, style_extract_spec` | @backend | **本波保留 `*_spec`**(兼容期不动) |
> 关键结论修正:因为 #5/#6 是 apps/api@backend 写import specs.py@llm 写)的产物,**「加 agent 要改 4 处」的散落比原稿估计更广**。本波**不强行清空 `*_spec` 导出**步骤6 的删除前置条件改为「`grep -rn '_spec' apps/api packages` 确认零消费方」(见 §5
- 用户 skill 已有按 name 查表机制:`SkillRegistry.get(name) -> AgentSpec``_to_spec` 把 DB 行转 spec`input_schema`/`output_schema` 强制 `None`),加载时 `validate_declaration` 校验 reads/writes ⊆ `KNOWN_TABLES`。**内置 spec 未走此路径——这是要弥合的缺口。**
「加一个 agent 要改 4 处」指:①写 prompt 常量 ②写 spec 实例 ③`__init__.py` 导出 ④下游 import 注册。方案A 把 ①→文件、②③→注册表,消除 ①②③ 的散落(④ 的 apps/api 路由因兼容期保留 `*_spec` 而本波不消除,列为后续波次)。
---
## 1. 设计原则
1. **只外置「会变的散文」,不外置「类型」。** prompt 是自然语言、迭代频繁、与代码逻辑正交,外置成 `.md` 让非工程改稿不碰 Python、diff 干净。而 `output_schema`/`input_schema` 是 Pydantic 类型,承载结构化解析与 `isinstance` 校验(`generation_node.py:154``outline_node.py:68``style_extract_node.py:71``review_node.py:122`**无法从文本推导、无法运行时安全构造**,必须留 Python`SCHEMA_CATALOG` 作 name→type 的**唯一真相源**见原则6
2. **一个 name 一个真相。** name 是全局稳定标识LangGraph 节点 key、`state['reviews']` key、`chapter_reviews` 落库列、日志标签)。重构后 name 仍是唯一主键:`SPECS[name]``prompts/<name>.md``SCHEMA_CATALOG[name]` 三者由 name 对齐,**任何一方缺失即 fail-fast**。**name 匹配语义锁死为精确字符串相等**——无大小写折叠、无连字符归一、无模糊匹配(评审 MEDIUM
3. **import 时确定性加载,绝不运行时惊喜。** `load_prompt` 在模块 import 期一次性读盘 + 内存缓存:保证 `spec.system_prompt` 是完整字符串(缓存断点前块 `cache=True` 要求字节稳定,不变量 #9),且单测可复现。文件缺失 = 启动失败fail-fast不 fallback、不用占位符。**`load_prompt` 不做任何动态插值(无 `.format`);任何未来需要运行期占位符的 prompt 不走 `load_prompt`,明确划在本波范围外**(评审 MEDIUM声明此约束
4. **内置与用户 skill 同构、同读接口,但权限边界不同。** 二者都产出 `AgentSpec``SpecResolver` 提供统一 `.get(name)`:内置走纯内存 `SPECS`**零 DB 依赖**),用户走 `SkillRegistry`DB必过 `validate_declaration`)。统一**读接口**,不统一**信任级别**。**`get(name)` 命中内置 name 时绝不触发任何 DB 调用**(评审 HIGH§3.5 + §6#7 断言)。
5. **不动缓存原理,只换 prompt 来源。** 字节稳定性的责任边界不变:`system_prompt` 仍整块进 `system``cache=True`volatilelatest_state/outline仍在断点后。改的只是「这块文本从常量来」→「从文件来」。
6. **缓存字节稳定 = 运行时值金标准 + 文件层契约CRITICAL本版新增的核心原则。** 因为常量含反斜杠折行,**金标准 fixture 必须从 AST `literal_eval` 的运行时字符串值计算 sha256绝不从源码文本计算**。`prompts/<name>.md` 的**物理换行必须 ≡ 运行时字符串的换行**——即:导出时,凡运行时无换行处,`.md` 里就是同一物理长行(放弃反斜杠折行带来的「源码可读性」,换取「文件即运行时真相」)。这是字节稳定的根。
---
## 2. 目标目录结构before / after
```
packages/agents/ww_agents/
__init__.py # before: 导出 21 个 *_spec + 22 schema 类 + AgentSpec
# after: 导出 AgentSpec(re-export from spec_model) + SPECS
# + load_prompt + SCHEMA_CATALOG + schema 类
# + 兼容期保留 *_spec用 __all__/as 显式 re-export 避 F401
specs.py (804行) # before: AgentSpec 类 + 21 prompt 常量 + 21 spec 实例
- ─────────────────────────────────────────────────────────────────────
spec_model.py (新, ~30) # after: 仅 AgentSpec 类定义frozen Pydantic
prompt_loader.py (新, ~55) # after: load_prompt(name) + PROMPTS_DIR + 缓存 + 规整 + fail-fast
schema_catalog.py (新, ~40) # after: SCHEMA_CATALOG: dict[name, type|None](唯一真相源)
specs.py (~140) # after: 21 个 spec 声明system_prompt=load_prompt(name),
# output_schema=SCHEMA_CATALOG[name]+ SPECS dict
prompts/ # after: 21 个外置散文(文件名 = spec.name
continuity.md outliner.md foreshadow.md pace.md style_extract.md
style.md # ← spec.name="style"(非 style_drift易错点单测覆盖
refiner.md worldbuilder.md
character-gen.md # ← 连字符 name非变量名 character_gen_spec
brainstorm.md book-title.md blurb.md name.md golden-finger.md
glossary.md opening.md fine-outline.md continue.md expand.md
de-ai.md teardown.md
packages/skills/ww_skills/
skill_registry.py # 改:加载/入库校验期拒绝与内置 name 同名的 skill保留命名空间前移见 §3.5
spec_resolver.py (新, ~60) # after: SpecResolver合并 SPECS(内置,内存) + SkillRegistry(用户,DB)
toolbox_registry.py # 改GeneratorTool.spec 改按 SPECS 取,不直接 import *_spec
# repo-root@devops owns
.gitattributes # 新增/改prompts/*.md text eol=lf防 CRLF 漂移)
packages/agents/pyproject... # 改wheel/sdist include prompts/*.md数据文件随包分发
.github/workflows/ci.yml # 改新增「build wheel→裸装→import SPECS」冒烟步骤
```
**文件命名铁律**`prompts/<spec.name>.md`name 用 spec 的**连字符 name**,不是 Python 变量名。`style` agent 的 spec.name 是 `"style"``style.md`——已知易错点,单测必须覆盖。
---
## 3. 关键接口签名与归属包
### 3.1 `AgentSpec` 模型 — `packages/agents/ww_agents/spec_model.py`@llm
`specs.py` 原样抽出,**字段不变**(保持 frozen、`system_prompt: str` 仍是 str不改 Callable
```python
class AgentSpec(BaseModel):
model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True)
name: str
tier: Tier
system_prompt: str # 仍是 str值在 import 时由 load_prompt 填入
input_schema: type[BaseModel] | None = None
output_schema: type[BaseModel] | None = None
reads: list[str] = Field(default_factory=list)
writes: list[str] = Field(default_factory=list)
genre: str | None = None
scope: str = "builtin"
```
> 决策:`system_prompt` 保持 `str`import 时求值),不改 `Callable`——缓存断点前块要确定字符串;改 Callable 会破坏 frozen/序列化,且违背确定性加载。
> **`__init__.py` re-export 纪律**(评审 MEDIUM`AgentSpec` 改从 `spec_model` re-export现有 `from ww_agents import AgentSpec` 的全部消费方(`skill_registry`/`skill_permissions`/`graph`/`generation_node`/`toolbox` 路由等)**不受影响**re-export 后 import 路径不变)。`__init__.py` 必须用显式 `__all__` 或 `import ... as ...` 重导出,避免 ruff `F401 unused-import`。§6 增 import-smoke 断言这些路径仍可用。
### 3.2 `load_prompt` — `packages/agents/ww_agents/prompt_loader.py`@llm
```python
PROMPTS_DIR: Final = Path(__file__).parent / "prompts"
_CACHE: dict[str, str] = {}
def load_prompt(name: str) -> str:
"""按 spec.name 读 prompts/<name>.md → 规整 → 缓存 → 返回完整 UTF-8 文本。"""
```
**规整规则(锁死缓存字节,决策级——已从「建议」升级,评审 HIGH/MEDIUM**,依次:
1. **去 BOM**:用 `encoding="utf-8-sig"` 读(或读后剥 ``)——`utf-8` **不会**自动剥 BOM带 BOM 的 `.md` 会污染字符串首字节破缓存(评审 missingItem
2. **行尾归一**`.replace("\r\n", "\n").replace("\r", "\n")`CRLF/CR → LF
3. **Unicode 归一化**`unicodedata.normalize("NFC", text)`——中文/全角标点存在 NFC/NFD 跨平台归一差异的理论缝隙,显式假定并强制 **NFC**(评审 missingItem
4. **尾换行策略(决策,方案 B 顺生态)**:返回 `text.rstrip("\n")`(吞掉文件尾部所有 LF**不补回**)。这样 `.md` 文件**允许**带 ≤1 个尾换行(顺 Prettier / editorconfig / `end-of-file-fixer` 默认),而运行时字符串无尾换行(与旧常量逐字等价,旧常量均以「。」结尾、无尾换行)。
**字节稳定的双层契约(评审 HIGH堵住「loader rstrip 吞掉差异后文件与字符串解耦」的静默漂移)**
- **真相源** = `load_prompt` 规整后的返回值(即 `spec.system_prompt`),由 §6#5 sha256 金标准锁死。
- **文件层** 额外加一条独立断言:每个 `prompts/*.md` 的磁盘原始字节里,**尾部 LF 数量 ≤ 1**§6#13)。这样即便有人误补多个尾换行,文件层测试先红,不会被 loader 静默吞掉。
**fail-fast**:文件不存在 → `PromptNotFoundError`import 期崩,不 fallback、不返回 `""`)。
**`.gitattributes`**@devops 写):`prompts/*.md text eol=lf` —— 管行尾风格;尾换行有无由上面文件层断言守。
### 3.3 `SCHEMA_CATALOG` — `packages/agents/ww_agents/schema_catalog.py`@llm
**name→output type 的唯一真相源**(评审 LOW消除 spec 字段与 catalog 双真相)。原稿的 `(input, output)` 元组**收敛为只存 output**(评审 KISS/YAGNI21 个 input 恒 None、本波不启用不造尚不存在的结构化入参槽
```python
# name → output_schemainput 全 None本波不建入参槽——YAGNI
SCHEMA_CATALOG: Final[dict[str, type[BaseModel] | None]] = {
"continuity": ContinuityReview,
"outliner": OutlineResult,
# ...其余 18 个真类...
"refiner": None, # 唯一纯文本 writerNone 是合法值
}
def output_schema_for(name: str) -> type[BaseModel] | None:
return SCHEMA_CATALOG[name]
```
> 未来若真出现结构化入参,再新建 `INPUT_SCHEMA_CATALOG` 或回到元组;理由记入 `decisions.md`。
### 3.4 `SPECS` 注册表 — `packages/agents/ww_agents/specs.py`@llm
**单向派生**`system_prompt` 一律来自 `load_prompt(name)``output_schema` 一律来自 `SCHEMA_CATALOG[name]`(不再写「或直接 ContinuityReview」的二义来源评审 LOW
```python
continuity_spec = AgentSpec(
name="continuity", tier="analyst",
system_prompt=load_prompt("continuity"), # ← 唯一来自 loader
output_schema=SCHEMA_CATALOG["continuity"], # ← 唯一来自 catalog
reads=["chapter_digests", "characters", "world_entities"], writes=[],
)
# ...其余 20 个同形...
SPECS: Final[dict[str, AgentSpec]] = {s.name: s for s in (
continuity_spec, outliner_spec, foreshadow_spec, pace_spec, style_extract_spec,
style_drift_spec, refiner_spec, worldbuilder_spec, character_gen_spec,
brainstorm_spec, book_title_spec, blurb_spec, name_spec, golden_finger_spec,
glossary_spec, opening_spec, fine_outline_spec, continue_spec, expand_spec,
de_ai_spec, teardown_spec,
)}
assert len(SPECS) == 21, "SPECS name 冲突或缺失" # 唯一性 + 数量自检import 期)
# 四审受信保留名 —— 独立显式白名单(评审 MEDIUM安全边界锚在此不依附派生集合
REVIEW_RESERVED_NAMES: Final[frozenset[str]] = frozenset(
{"continuity", "foreshadow", "style", "pace"}
)
```
> `*_spec` 模块变量在兼容期保留toolbox / 节点 / apps/api 路由旧 import 不破)。**`SPECS[name] is *_spec`(同一实例)是不变量**(评审 LOW / missingItem兼容期绝不允许 `SPECS["continuity"]` 与 `continuity_spec` 是两个对象——否则缓存断点/日志 name 出现双真相。§6 锁定。
### 3.5 `SpecResolver` — `packages/skills/ww_skills/spec_resolver.py`@backend
**依赖方向**resolver 在 @backend,向上依赖 @llm`SPECS` + 自包 `SkillRegistry`。**不反向**(已核验 `ww_agents` 不依赖任何下游)。
```python
class SpecResolver:
"""内置(SPECS, 纯内存) + 用户 skill(SkillRegistry, DB) 的统一只读解析入口。"""
def __init__(self, builtin: dict[str, AgentSpec], skills: SkillRegistry) -> None: ...
@classmethod
def build(cls, skills: SkillRegistry) -> "SpecResolver":
return cls(builtin=dict(SPECS), skills=skills) # 纯合并,不做冲突校验(已前移,见下)
def get(self, name: str) -> AgentSpec:
"""先查内置 SPECS纯内存、零 DB未命中才查 SkillRegistry都无 → NOT_FOUND。
内置 name 永不触发 DB 调用。"""
def output_schema_for(self, name: str) -> type[BaseModel] | None:
"""命中内置 → SCHEMA_CATALOG[name];纯用户 skill → None精确 name无模糊命中。"""
def names(self) -> list[str]: ...
def list_scope(self, scope: str) -> list[AgentSpec]: ...
```
**override / 优先级裁决 —— 守卫前移(评审 HIGH核心修正**
- 「内置 name 为保留命名空间、用户 skill 不可覆盖」的校验**前移到 SkillRegistry 的 skill 入库/加载校验处**(与 `validate_declaration` 同处),**不放在 `resolver.build` 的读路径**。
- 理由:放读路径会让一个坏 skill恰好叫 `continuity`)令**每次** `build` 抛错、把安全校验耦合进写章/审稿热路径、并可能让正常请求 500而且 `build` 若每请求重建,「拒绝成败取决于请求时 DB 状态」违背原则3 的确定性。
- 前移后:`get(name)` 对内置永远是**纯内存查表**(零 DB、零运行时分叉、确定性坏 skill 在**入库时**即被拒(`AppError(VALIDATION)`,错误归属清晰、早 fail-fast
- 安全断言锚在 **`REVIEW_RESERVED_NAMES` 显式白名单**§3.4),不依附会随重构漂移的 `set(SPECS)`
> **`output_schema_for`(原 `bind_output_schema`)接线说明(评审 HIGH原稿是悬空 API**:当前 8 个工具箱新工具的 `spec` **本就是内置实例**`output_schema` 非 None故「内置 name 补绑」这条路径**本波实际无触发场景**。因此:本波**保留 `output_schema_for` 作为 resolver 的只读查询方法**,但**不接线进 `run_generator`、不在 §6 断言其被调用**,明确标注「为后续『用户 skill 复用内置 schema』路径留缝」。纯用户自定义仅 DB JSON Schema dict无 Python 类)→ 恒 `None`,运行期 JSON-Schema→Pydantic 动态构造**明确划出本波范围**(不引入不可信类型构造)。
**编排器本波不接 resolver**`graph.py`/`review_node.py`@llm)当前直接用 `REVIEW_SPECS`(内置元组),本波不强制改它走 resolver——避免本波跨两个 owner 大改。resolver 首个消费方是 @backend`toolbox_registry`(按 name 取 spec
---
## 4. 逐文件改动清单
**packages/agents@llmsole writer**
- 新增 `spec_model.py`:抽出 `AgentSpec`(零行为变化)。
- 新增 `prompt_loader.py``PROMPTS_DIR` + `load_prompt` + `_CACHE` + `PromptNotFoundError`去BOM / LF / NFC / rstrip尾LF / fail-fast
- 新增 `schema_catalog.py``SCHEMA_CATALOG: dict[name, type|None]`21 项,唯一真相源)+ `output_schema_for`
- 新增 `prompts/<name>.md` ×21每个文件 = 对应常量**运行时字符串值**的逐字外迁(程序化导出,物理换行 ≡ 运行时换行)。
- 修改 `specs.py`:删 21 常量 + `AgentSpec` 类;`system_prompt=load_prompt(name)` + `output_schema=SCHEMA_CATALOG[name]`;建 `SPECS` + assert + `REVIEW_RESERVED_NAMES`;从 `spec_model` 重导出 `AgentSpec`。降至 ~140 行。
- 修改 `__init__.py`:显式 `__all__` 重导出 `AgentSpec`/`SPECS`/`load_prompt`/`SCHEMA_CATALOG`/schema 类 + 兼容期 `*_spec`(避 F401
**packages/skills@backendsole writer**
- 新增 `spec_resolver.py``SpecResolver`build/get/output_schema_for/names/list_scope
- 修改 `skill_registry.py`**入库/加载校验期拒绝与内置 name`REVIEW_RESERVED_NAMES`)同名的 skill**(保留命名空间前移)。`_to_spec``output_schema=None` 不变。
- 修改 `toolbox_registry.py``GeneratorTool.spec` 改按 `SPECS["<name>"]` 取,删对 8 个生成器 `*_spec` 的直接 importlegacy `spec=None` 工具不变。
**packages/core/orchestrator@llm** — 本波**不改**`REVIEW_SPECS` / 节点 / §6.5 precheck / chain 仍直接用内置 spec 实例,行为不变)。文档标注后续波次可改走 resolver。
**apps/api routers@backend** — 本波**不改**`toolbox.py`/`outline.py`/`style.py` 兼容期继续 `from ww_agents import *_spec`;因 `SPECS[name] is *_spec`,拿到的是同一实例)。后续波次再切 `SPECS`
**repo-root config@devops跨 owner——需 @backend/@llm 在 PROGRESS 开请求项,不可自行写)**
- 新增 `.gitattributes``prompts/*.md text eol=lf`(仓库当前**无** `.gitattributes`,由 @devops 新建)。
- 修改 `packages/agents` 构建配置wheel/sdist `force-include`/package-data 显式纳入 `prompts/*.md`
- 修改 `.github/workflows/ci.yml`新增「build wheel → 裸环境安装 → `python -c "import ww_agents; assert ww_agents.SPECS"`」冒烟步骤。
**tests@qa/ 各 owner 单测** — 见 §6。
---
## 5. 有序迁移步骤(每步独立过门禁 = ruff + format + **mypy** + pytest评审 missingItem 补 mypy
> 原则:先建能力且与旧常量**并存等价**,验证零字节差后再删常量,最后桥接消费方。
**步骤 0 — 运行时值金标准(防漏/防孤儿/防字节漂移的锚CRITICAL 修正)**
- 一次性脚本/测试:对每个 `*_SYSTEM_PROMPT`**AST `literal_eval` 的运行时字符串值****不是源码文本**因含反斜杠折行dump `{name: sha256(runtime_value)}``tests/fixtures/prompt_hashes.json`。这是迁移正确性的唯一金标准。
- **TDD RED**:此 fixture 测试在 `prompt_loader` 实现前先提交并 RED`load_prompt` 未实现 → ImportError 红),严格满足 RED→GREEN评审 LOW
**步骤 1 — 抽 `AgentSpec` 到 `spec_model.py`**
- 纯搬移 + `specs.py`/`__init__.py` 改 import`__all__` 重导出避 F401。门禁绿行为零变化
**步骤 2 — 落 `prompt_loader.py` + `prompts/*.md`(与常量并存)**
- 用脚本把每个常量的**运行时值**写入 `prompts/<name>.md`(程序化导出,物理换行 ≡ 运行时换行,杜绝手抄漂移)。
- 加测试 A`for name: sha256(load_prompt(name)) == fixture[name]`对齐步骤0 金标准)。
- **加测试 B步骤2 当步守卫,评审 MEDIUM**`{p.stem for p in PROMPTS_DIR.glob("*.md")} == set(fixture.keys())` —— 用 fixture 的 name 全集当锚,**当步**拦住多写/少写/拼错 `.md`不等到步骤3 建 SPECS
- 加测试 C文件层每个 `.md` 尾部 LF ≤ 1。此刻 specs.py 仍用常量——**先证 loader≡常量逐字等价**。
**步骤 3 — 切 specs.py 到 `load_prompt` + 建 `SPECS` + `SCHEMA_CATALOG` + `REVIEW_RESERVED_NAMES`**
- 删 21 常量;`system_prompt=load_prompt(name)``output_schema=SCHEMA_CATALOG[name]`;建 `SPECS` + assert。
- 回归:`sha256(SPECS[name].system_prompt) == fixture[name]`(证删常量后零字节差);`SPECS["<x>"] is <x>_spec`(同一实例);`all(SPECS[n].output_schema is SCHEMA_CATALOG[n])`。门禁绿。
**步骤 4 — `SpecResolver` + SkillRegistry 守卫前移skills 包)**
- 实现 resolver内置纯内存+ SkillRegistry 入库期拒绝同名内置。单测:内置/用户对齐、内置 `get` 零 DB、入库同名拒绝。门禁绿。
**步骤 5 — 桥接 `toolbox_registry`**
- `GeneratorTool.spec` 改走 `SPECS`;删 8 个生成器 `*_spec` 的直接 import。前端契约不变GeneratorTool 描述符字段不变),无需 `pnpm gen:api`。门禁绿。
**步骤 6 — 清理(前置条件已修正)**
- 前置条件:`grep -rn '_spec' apps/api packages` 确认**无任何消费方**依赖 `*_spec`(含 apps/api 3 路由 + 编排器)。
- 因 apps/api 路由本波保留 `*_spec`**本波不删 `*_spec` 导出**——仅更新 `ARCHITECTURE.md §5.1``memory/decisions.md``PROGRESS.md`把「apps/api 路由 + 编排器切 resolver」列为后续波次。
**防孤儿 / 防漏(贯穿)**步骤2 测试B文件集 == fixture name 全集)+ 步骤3 三集合恒等 + 程序化导出(杜绝手抄)+ 运行时值 sha256 金标准(杜绝字节漂移)。
---
## 6. 测试计划TDD全程 mock不触真 LLM
| # | 测试 | 类型 | 断言 |
|---|---|---|---|
| 1 | 注册表唯一性 | unit | `len(SPECS)==21`;无重复 nameimport `specs` 不抛 |
| 2 | md ↔ spec ↔ catalog 一一对应 | unit | `set(SPECS) == {p.stem for p in PROMPTS_DIR.glob("*.md")} == set(SCHEMA_CATALOG)`(覆盖 `style.md`/`character-gen.md` 连字符) |
| 3 | `load_prompt` 正常 + 缓存 | unit | 已知 name 返非空 UTF-8二次调用命中 `_CACHE` |
| 4 | `load_prompt` fail-fast | unit | 未知 name → `PromptNotFoundError`;不 fallback、不返 `""` |
| 5 | **缓存前缀字节回归核心CRITICAL** | unit | `sha256(SPECS[name].system_prompt) == fixture[name]`全21**fixture 取自 AST literal_eval 运行时值** |
| 6 | 行尾确定性 | unit | 含 CRLF/CR 的临时 md 经 `load_prompt` 规整为 LFhash 与 LF 版一致 |
| 6b | **BOM + NFC 规整** | unit | 带 BOM 的 md → 首字节无 ``NFD 全角标点 md → 与 NFC 版 hash 一致 |
| 7 | resolver 内置/用户对齐 + **零 DB** | unit | `get("continuity")` 命中内置且**注入 fake registry 断言其未被调用**`get("<用户skill>")` 命中 DB都无 → `NOT_FOUND` |
| 8 | **内置保留名守卫前移** | unit | SkillRegistry **入库**与 `REVIEW_RESERVED_NAMES`/内置同名 skill → `VALIDATION`resolver.build **不**因此抛 |
| 9 | `output_schema_for` | unit | 内置 name → `SCHEMA_CATALOG[name]` 真类;`refiner``None`;纯用户 skill → `None`**拼错近似 name`character_gen`)→ None 不抛、不误命中 `character-gen`** |
| 10 | 边界refiner 纯文本 | unit | `SPECS["refiner"].output_schema is None``SCHEMA_CATALOG["refiner"] is None`§2 一致性不因 None 误判 |
| 11 | toolbox 绑定 | unit | 每个新工具 `GeneratorTool.spec is SPECS[key]`legacy 工具 `spec is None` 不变 |
| 12 | 编排器无回归 | integration | review/generation/outline/style_extract 节点用 `SPECS` 内置 specmock gatewaysystem 块文本 == 旧行为 |
| 13 | **文件层尾换行契约** | unit | 每个 `prompts/*.md` 磁盘原始字节尾部 LF ≤ 1 |
| 14 | **同一实例不变量** | unit | `SPECS["continuity"] is continuity_spec``REVIEW_RESERVED_NAMES ⊆ set(SPECS)`且这4个 `scope=="builtin"``writes==[]` |
| 15 | **import-smokeapps/api + 编排器)** | integration(@backend/@qa) | `from ww_agents import AgentSpec/outliner_spec/refiner_spec/style_extract_spec/continuity_spec` 不抛,且拿到的对象 `is SPECS[name]` |
| 16 | **打包冒烟** | CI(@devops) | build wheel → 裸装 → `import ww_agents; assert ww_agents.SPECS``PromptNotFoundError`(防 `.md` 漏带,源码树 pytest 测不出) |
覆盖率沿用全局 ≥80%。所有 LLM 调用注 fake gatewayCLAUDE.md TDD 纪律)。
---
## 7. 风险表与缓解
| 风险 | 等级 | 说明 | 缓解 |
|---|---|---|---|
| **反斜杠折行 → 运行时换行塌缩 → 金标准取错来源** | CRITICAL | 常量含 `\<newline>`,源码物理换行 ≠ 运行时换行;若 fixture 从源码文本算 hash会「看起来对、字节不对」静默破缓存 | 金标准 fixture 取 **AST `literal_eval` 运行时值**;导出时 `.md` 物理换行 ≡ 运行时换行§6#5/原则6 |
| 缓存字节漂移(行尾/BOM/NFC/尾换行) | CRITICAL | CRLF/BOM/NFD/尾空行使 `system_prompt` 字节变化 → 缓存失配/语义微变(不变量#9 | `load_prompt` 去BOM(`utf-8-sig`)+LF归一+NFC+rstrip尾LF§6#5/#6/#6b/#13`.gitattributes eol=lf`@devops |
| 尾换行被编辑器/Prettier/end-of-file-fixer 注入 | HIGH | 工具默认补尾 `\n`,与「字节稳定」对抗 | 决策方案B顺生态md 允许 ≤1 尾换行loader `rstrip("\n")`**双层契约**——文件层 §6#13 守「尾LF≤1」、运行时 §6#5 守真相源,二者解耦的静默漂移被堵 |
| SpecResolver 引入运行时不确定性 / 安全校验耦合热路径 | HIGH | build 每请求重建 + 读路径校验同名 → 解析结果依赖 DB 当下、坏 skill 让正常请求 500 | 守卫**前移**到 SkillRegistry 入库校验;`get` 内置纯内存零 DB§3.5 / §6#7/#8 |
| `output_schema` 误外置 / bind 悬空无接线 | HIGH | 类型无法从文本推导;`output_schema_for` 原稿无消费方 | schema 永留 `SCHEMA_CATALOG`(唯一真相);`output_schema_for` 本波**不接线、不断言被调用**,明标为后续留缝 |
| 消费方遗漏apps/api 3 路由 + §6.5 precheck | HIGH | 原稿漏点 → 步骤6 删 `*_spec` 会破 apps/api import | §0 补全 6 点矩阵apps/api 本波保留 `*_spec`步骤6 前置改为全仓 grep§6#15 import-smoke |
| name 漂移破坏落库映射 / 节点 key | HIGH | 改名或文件名拼错(`style``style_drift`、连字符) | name 冻结 + 精确匹配§6#2 三集合恒等;程序化导出按 spec.name`REVIEW_SPECS` 不改序 |
| 用户 skill 覆盖内置 name 偷换四审 prompt | HIGH安全 | 不可信输入冒用 `continuity` 等 | SkillRegistry 入库期拒同名(锚 `REVIEW_RESERVED_NAMES` 显式白名单非派生集合§6#8/#14 |
| **打包遗漏 `.md`(源码树 pytest 假绿)** | HIGH升级 | wheel 默认不打非-.py 数据文件CI 用源码树 pytest 测不出fail-fast 仅在裸装时触发 | @devops 在构建配置 force-include `prompts/*.md`CI 加「build wheel→裸装→import」冒烟§4 / §6#16 |
| 跨 owner 改动(@llm specs ↔ @backend resolver/toolbox ↔ @devops config | MEDIUM | 依赖方向 + `.gitattributes`/打包/CI 属 @devops | 依赖单向skills→agents`.gitattributes`/pyproject/CI 由 @devops 实施,@llm/@backend 在 PROGRESS 开请求项 |
| 迁移分步中途半成品 | MEDIUM | 步骤2/3 之间 | 步骤2 先证 loader≡常量运行时值 sha256+ 目录完整性守卫测试B步骤3 才删常量;每步独立过门禁(含 mypy |
| git 历史追溯 | LOW | 删 prompt 行后 `git log -p specs.py` 追不到旧演进 | 迁移 commit body 注明「prompt 逐字外迁至 prompts/<name>.md运行时值 sha256 见 fixture」后续改动落 `prompts/*.md` 更聚焦 |
---
## 8. 协作落点(多 agent 纪律)
**`memory/contracts.md`**C6 Agent specification 下登记):
- `SpecResolver.get(name) -> AgentSpec` **是契约**@backend 供 resolver + 入库守卫,@llm`SPECS`/`SCHEMA_CATALOG`/`REVIEW_RESERVED_NAMES`/`load_prompt`)。约束:① 内置 name 为保留命名空间、不可被用户 skill 覆盖(守卫在 **SkillRegistry 入库期**);② `get` 内置纯内存、零 DB`load_prompt` import 期确定性去BOM/LF/NFC/rstrip尾LF/fail-fast`SPECS[name] is *_spec` 兼容期同一实例。标 `稳定` 后 toolbox / 后续波次才动。
- C1LLM 网关)**不改**`LlmRequest`/`Block.cache` 不动;`system_prompt` 仍整块 `cache=True`)。
**`memory/decisions.md`**append一条一事实
- 「prompt 外置方案A散文→`prompts/<spec.name>.md``load_prompt` import 期读盘+缓存+去BOM(utf-8-sig)+LF归一+NFC+`rstrip('\n')`+fail-fast**金标准 fixture 取 AST literal_eval 运行时值**(因常量含反斜杠折行,源码物理换行≠运行时换行);`.md` 物理换行≡运行时换行;尾换行决策=方案Bmd 允许≤1 尾LFloader rstrip 不补回,文件层断言守 尾LF≤1。」
-`SCHEMA_CATALOG` 收敛为 `dict[name, type|None]`(去掉恒 None 的 input 槽YAGNI是 name→output 唯一真相源,`SPECS[name].output_schema` 由它派生。」
- 「内置 name`REVIEW_RESERVED_NAMES`=continuity/foreshadow/style/pace为保留命名空间守卫**前移至 SkillRegistry 入库校验**(非 resolver 读路径),用户 skill 同名 → `VALIDATION`(安全边界,呼应不变量#3)。」
-`load_prompt` 不支持运行期占位符/插值;需插值的 prompt 不走此路径,属本波范围外。」
**`memory/gotchas.md`**`prompts/*.md` 文件名按 **spec.name连字符**,非 Python 变量名;`style` agent 的 name 是 `"style"``style.md`;常量含反斜杠折行——**外迁/比对必须用运行时值**,别用源码文本;`.gitattributes``prompts/*.md text eol=lf`wheel 必须 force-include `prompts/*.md`,否则裸装 import 崩(源码树 pytest 测不出)。
**`PROGRESS.md` 任务拆分**(新增一个 wave每个 `🔵→✅` 独立过门禁,含 mypy
1. `@llm` — 步骤 03fixture 取运行时值 / spec_model / loader / prompts / SPECS / SCHEMA_CATALOG / REVIEW_RESERVED_NAMES删常量sha256 回归)。
2. `@backend` — 步骤 45SpecResolver + SkillRegistry 入库守卫前移 + toolbox 桥接)。**依赖** 第1项 `SPECS` 稳定后开工。
3. `@qa` — §6#12/#15 集成回归(编排器无回归 + apps/api import-smoke
4. `@devops``.gitattributes` + wheel package-data + CI wheel 冒烟§6#16)。**与第1项并行可起CI 冒烟依赖第1项 prompts 落盘。**
5. `@llm`/`@backend`**可选后续波**)— apps/api 3 路由 + 编排器节点改走 resolver然后才删 `*_spec` 导出。
**跨所有权请求项(评审 missingItem原稿漏**:本方案**需要 @devops 触碰 repo-root config**`.gitattributes` 新建 + `packages/agents` 打包配置 + CI——@llm/@backend **不可自行写**这些文件,须在 `PROGRESS.md` 给 @devops 开请求项。@llm 只动 `packages/agents`@backend 只动 `packages/skills`。对 `packages/shared`/OpenAPI **零触碰**GeneratorTool 描述符字段不变 → 前端无需 `pnpm gen:api`)。
---
## 9. 边界情形显式处理
1. **`refiner`(无 output_schema 纯文本 writer**:走 `load_prompt("refiner")` + `SPECS["refiner"]``output_schema=None` 合法,`SCHEMA_CATALOG["refiner"]=None`。§6#2 把 key 存在当断言(不断言值非 None节点侧 `if expected is not None: isinstance(...)` 逻辑不变。
2. **input_schema 全 None**21 个 `input_schema` 恒 None入参为序列化文本`SCHEMA_CATALOG` **本波只存 output**(不造尚不存在的 input 槽YAGNI未来需要时另建 `INPUT_SCHEMA_CATALOG`,理由记 decisions。
3. **toolbox `GeneratorTool` 绑定**8 个新工具 `spec` 改指 `SPECS[key]`同一实例§6#113 个 legacy `spec=None` 不变。
4. **用户 skill 与内置 schema 对齐**:用户 skill 经 `SkillRegistry``output_schema=None``output_schema_for(name)` 仅当 name **精确命中**内置 `SCHEMA_CATALOG` 时返真类,否则 None。**name 精确匹配**(无大小写/连字符归一):拼错的近似 name`character_gen` vs `character-gen`)→ 返 None 不报错,**这是预期行为非 bug**§6#9)。本波该方法**不接线**,纯自定义 JSON-Schema→Pydantic 动态构造划出本波范围。
5. **name 比较语义统一**:入库守卫(拒同名)与 `output_schema_for`(补绑)用**同一套精确字符串相等**比较,无二义。
---
## 10. 评审回应(逐条处理 CRITICAL / HIGH findings
裁决:架构不变量+缓存=`changes_requested` · 项目规约=`approve` · 完备性=`changes_requested`。下列意见已全部折叠进 v2。
### 视角一:架构不变量 + 缓存前缀字节稳定性
**[CRITICAL] 反斜杠行延续 → 运行时换行塌缩,金标准取错来源** —— **采纳(核心修正)**。已核验 `CONTINUITY_SYSTEM_PROMPT``\<newline>`,源码物理换行 ≠ 运行时换行。处理:① 新增**原则6**「运行时值金标准 + 文件层契约」;② 步骤0 fixture 改为取 **AST `literal_eval` 运行时值**算 sha256§5 步骤0③ 锁死「`.md` 物理换行 ≡ 运行时字符串换行」(导出时凡运行时无换行处即同一物理长行,放弃反斜杠折行的源码可读性);④ §6#5 断言明确「fixture 取自运行时值」;⑤ 风险表升为头号 CRITICAL⑥ decisions/gotchas 记明。
**[HIGH] 尾换行策略与生态工具冲突** —— **采纳选方案B**。处理:① §3.2 尾换行从「建议」升级为**决策**——md 允许 ≤1 尾换行、loader `rstrip("\n")` 不补回;② **双层契约**:文件层 §6#13 断言「尾LF≤1」+ 运行时 §6#5 守真相源堵住「rstrip 吞差异后文件与字符串解耦」的静默漂移;③ 风险表第3行 + decisions 记明。
**[HIGH] SpecResolver 引入运行时不确定性 / 安全校验耦合热路径** —— **采纳(守卫前移)**。处理:① §3.5 把「内置保留名守卫」**前移到 SkillRegistry 入库校验**`resolver.build` 仅纯合并不校验;② `get` 内置纯内存、零 DB③ §6#7 加「内置 get 注入 fake registry 断言其未被调用」;④ §6#8 断言入库期拒绝、build 不抛;⑤ §4 `skill_registry.py` 列入改动。
**[MEDIUM] 四审受信 name 缺独立显式白名单** —— **采纳**。§3.4 新增 `REVIEW_RESERVED_NAMES` 显式常量;安全校验锚此(非 `set(SPECS)`§6#14 断言 `⊆ set(SPECS)` 且这4个 `scope=="builtin"``writes==[]`
**[MEDIUM] 打包分发风险被低估 / 源码树 pytest 假绿** —— **采纳(升级为 HIGH + 开工前必做)**。风险表升级§4 列 @devops 在构建配置 force-include `prompts/*.md`§6#16 加「build wheel→裸装→import」CI 冒烟§8 列 @devops 请求项。
**[LOW] schema_catalog 与 spec 实例双真相** —— **采纳**。§3.3/§3.4 定死单向派生:`SPECS[name].output_schema` 一律来自 `SCHEMA_CATALOG[name]`catalog 为唯一真相§6 步骤3 断言 `all(SPECS[n].output_schema is SCHEMA_CATALOG[n])`
**[missingItem] BOM / Unicode NFC 归一化** —— **采纳**。§3.2 规整加 `utf-8-sig` 去BOM + `unicodedata.normalize("NFC")`§6#6b 断言decisions 声明假定 NFC。
### 视角二项目规约审查verdict: approve
**[LOW] 全部 6 条(目录所有权/行数/TDD/命名不可变/协作/KISS-YAGNI/fail-fast 时机)** —— 合规,**采纳其改进建议**:① TDD RED——步骤0 fixture 在 loader 前先提交 RED§5 步骤0② SCHEMA_CATALOG 收敛为只存 output dict去恒 None 的 input 槽§3.3YAGNI③ 确认 catalog 非第二真相源§3.3/§3.4 单向派生);④ fail-fast 时机——守卫前移入库期§3.5,与视角一 HIGH 同向)。
**[missingItem] mypy 未点名** —— **采纳**§5「每步独立过门禁」明确含 `mypy packages apps`
**[missingItem] `__init__.py` re-export 的 F401** —— **采纳**§3.1 + §4 要求显式 `__all__`/`as` 重导出避 F401。
**[missingItem] `SPECS[name] is *_spec` 同一实例(覆盖 REVIEW_SPECS** —— **采纳**§3.4 列为不变量§6#14 断言。
**[missingItem] `.gitattributes` 跨 owner@devops** —— **采纳**§4/§8 明确 `.gitattributes`/打包/CI 归 @devops@llm/@backend 开 PROGRESS 请求项(仓库当前无 `.gitattributes`,已核验)。
### 视角三:完备性批评(漏了什么)
**[HIGH] 消费方清单漏 3 个 apps/api 路由** —— **采纳**。已核验 `toolbox.py:22`/`outline.py:23`/`style.py:25` import 内置 `*_spec`。处理:① §0 补全 **6 点消费方矩阵**(含 owner + 本波处理);② 修正「无需跨 owner」——apps/api 路由切换需 @backend 自有目录实施,本波**保留 `*_spec` 不动**;③ 步骤6 删除前置条件改为「全仓 grep含 apps/api确认零 `*_spec` import」④ §6#15 import-smoke。
**[HIGH] `bind_output_schema` 悬空 API 无接线** —— **采纳**。处理:① 重命名为 `output_schema_for`去掉「bind」暗示的写语义② §3.5 明确本波**无触发场景**8 个新工具的 spec 本就是内置实例、output 非 None故**保留为只读查询但不接线进 `run_generator`、不断言被调用**,标为后续留缝;③ §9.4 同步。
**[MEDIUM] name 精确匹配语义 + 拼错近似 name 失败模式** —— **采纳**。原则2 + §9.4/§9.5 声明**精确字符串相等**(无大小写/连字符归一§9.4 列「拼错近似 name → 返 None 不报错是预期行为」§6#9 加单测(`character_gen` → None不误命中 `character-gen`);入库守卫与补绑用同一套比较。
**[MEDIUM] 步骤2「独立过门禁」缺 prompts 目录完整性守卫** —— **采纳**。§5 步骤2 加测试B`{md stem} == set(fixture.keys())`(用 fixture name 全集当锚),**当步**拦多写/少写/拼错 md不等步骤3。
**[MEDIUM] 尾换行策略仍是「建议」+ 未声明无插值假设** —— **采纳**。§3.2 升级为**决策**方案B见视角一 HIGH原则3 + decisions 声明「`load_prompt` 不支持运行期占位符/插值,需插值的 prompt 属本波外」。
**[LOW] 回归未覆盖 apps/api 路由 + §6.5 precheck continuity 复用 + 同一实例断言** —— **采纳**。§6#14`SPECS["continuity"] is continuity_spec`+ §6#15apps/api import-smoke拿到对象 `is SPECS[name]`);已核验 `generation_node.py` §6.5 precheck 复用 `continuity_spec`,因同一实例不变量而无回归。
**[missingItem] `__all__` 大改破坏面** —— **采纳**§3.1 说明 `AgentSpec` 改 re-export 后 `from ww_agents import AgentSpec` 的全部现有消费方skill_registry/skill_permissions/graph/generation_node/toolbox 路由import 路径不变、不受影响§6#15 import-smoke 覆盖。
---
## 附:相关文件路径(绝对)
- 现状声明:`packages/agents/ww_agents/specs.py``.../__init__.py``.../schemas.py`
- 用户 skillresolver 对齐基准 + 守卫前移落点):`packages/skills/ww_skills/skill_registry.py``.../skill_permissions.py``.../toolbox.py``.../toolbox_registry.py`
- 编排器消费点(本波不改):`packages/core/ww_core/orchestrator/{graph.py,review_node.py,generation_node.py,outline_node.py,style_extract_node.py,collect.py}``.../chain/graph.py`
- apps/api 路由消费点(本波保留 `*_spec`,评审补全):`apps/api/ww_api/routers/{toolbox.py,outline.py,style.py}`
- 待外置目标目录(新建):`packages/agents/ww_agents/prompts/`
- @devops 配置落点:`.gitattributes`(新建)、`packages/agents` 打包配置、`.github/workflows/ci.yml`
- 协作回写:`memory/{contracts.md,decisions.md,gotchas.md}``PROGRESS.md``ARCHITECTURE.md`§5.1