diff --git a/.claude/skills/design-story-foundation/SKILL.md b/.claude/skills/design-story-foundation/SKILL.md
index 173f433..2f0c251 100644
--- a/.claude/skills/design-story-foundation/SKILL.md
+++ b/.claude/skills/design-story-foundation/SKILL.md
@@ -1,6 +1,6 @@
---
name: design-story-foundation
-description: 在正式规划前固化作品根设定,并按前三章、前十章、前五十章的追读节奏生成可比较的前期设计候选。用户仍在单文档前期设计阶段时使用;不写正文、不落库、不替用户定稿。
+description: 在正式规划前固化作品根设定,按固定三级章节树生成可比较的前期设计候选。用户仍在单文档前期设计阶段时使用;不写正文、不落库、不替用户定稿。
---
# 作品设定初始化
@@ -13,24 +13,35 @@ description: 在正式规划前固化作品根设定,并按前三章、前十
## 元数据驱动:输入与权威顺序
-每次执行先冻结一份 `design-story-foundation/v1` 任务包,至少包含:
+每次执行先冻结一份 `design-story-foundation/v2` 任务包,至少包含:
1. 作品名、前期设计 SoT 路径和本轮候选数量。
2. 根设定全文,以及尚未解决的冲突和问题。
3. 与本书有关的完整用户对话记录及决策状态。
-4. 前三章、前十章、前五十章和全书阶段的节奏要求。
-5. 参考书证据、禁止照搬项、候选目录、编号号段和篇幅门槛。
+4. 前三章、前十章、前五十章和全书阶段的节奏要求;用户指定阶段模型时一并写入。
+5. 参考书证据、禁止照搬项、候选目录、编号号段;如有数量或字数门槛,仅在被显式指定时写入。
6. 每个候选的唯一编号与输出路径;除此之外,各子代理输入完全相同。
冲突时按“用户最新明确要求 > 根设定中的已定事实 > 用户已接受方案 > 待定建议”处理。AI 自己说过但用户未接受的方案不能升格为根设定。
-任务包的字段、状态和示例见 [候选生成合同](references/candidate-contract.md)。冻结后再派发;执行期间收到的新要求先更新任务包,旧候选随即失效,不允许边生成边暗改口径。
+任务包的字段、状态和示例见 [候选生成合同](references/candidate-contract.md);三级章节各自应写什么见 [章节内容规范](references/chapter-content-spec.md)。冻结后再派发;执行期间收到的新要求先更新任务包,旧候选随即失效,不允许边生成边暗改口径。
-本阶段产物不属于 23 型正式作品结构,字段权威就是 `design-story-foundation/v1` 候选合同。用户选定方案后,`plan-story` 才按 `meta/schemas/` 把内容转换成正式 Shadow 规划。
+本阶段产物不属于 23 型正式作品结构,字段权威就是 `design-story-foundation/v2` 候选合同。用户选定方案后,`plan-story` 才按 `meta/schemas/` 把内容转换成正式 Shadow 规划。
-## 根设定合同
+## 根设定与候选章节树的分工
-前期设计 SoT 的第一章固定为“根设定”。这里只放作品自身的稳定事实和叙事硬约束,包括标题承诺、主角前提、能力边界、成长台阶、披露节奏、正文表达要求和不能触碰的内容。
+前期设计 SoT 的第一章固定为“根设定”。根设定与三级候选树分两层,不互相吞并:
+
+```text
+根设定 上游稳定输入,回答“这部作品绝对不能偏离什么”
+三级候选树 本 Skill 的主要输出,回答“这部作品怎样被设计出来”
+```
+
+- 根设定只放作品自身的稳定事实和叙事硬约束:标题承诺、主角前提、能力边界、成长台阶、披露节奏、正文表达要求和不能触碰的内容。
+- 根设定不占九章目录;阶段状态、事件树、伏笔表和文风规范不得塞进根设定。
+- 候选中被用户确认的稳定事实,才回写根设定或进入后续正式规划。
+
+根设定本身的书写规则:
- 不写设计过程、代理分工、提示词、工具、候选比较或方案来源。
- 每个点只写一句完整话,连同标签控制在 30—60 个可见字符。
@@ -40,6 +51,32 @@ description: 在正式规划前固化作品根设定,并按前三章、前十
根设定只回答“这部作品必须是什么样”。候选如何产生、谁来产生、生成几份,属于本 Skill,不得回写根设定。
+## 固定三级章节树
+
+候选文档使用固定的九章三级树,完整标题、职责与内容要点见 [章节内容规范](references/chapter-content-spec.md):
+
+```text
+一、作品定位与核心命题
+二、故事世界与社会系统
+三、角色与关系
+四、故事机制与关键实体
+五、阶段与状态变化
+六、情节架构与全书大纲
+七、主题与读者体验弧线
+八、信息、悬念与伏笔管理
+九、叙事执行与一致性
+```
+
+层级硬规则:
+
+1. 一级章节是一个创作设计领域;二级章节是该领域要回答的一组问题;三级章节是可以独立填写、审查和引用的内容单元;`S` 条目必须挂在三级章节下。
+2. 九个一级章节和全部二级、三级标题固定,顺序不得调整,所有候选逐字同构。
+3. 不允许四级标题,不允许在二级标题下直接放 `S` 条目。
+4. 三级标题不按“初期 / 中期 / 后期”复制;阶段变化统一由第五章状态矩阵和实体卡阶段字段表达。
+5. 阶段数量由作品决定:本书可用初期、中期、后期,其他作品可用两阶段、四阶段或卷、篇、幕,但必须写清阶段边界。
+6. 三级章节无适用内容时必须显式标注“不适用及原因”,不得留空。
+7. `## 目录`可以存在,不属于内容章节,不参加号段和内容判定。
+
## 先定节奏,再铺设定
设定必须从追读节奏反推。先回答各阶段读者为什么翻下一章,再设计能支撑这些事件的能力、资源、人物和世界规则。
@@ -51,7 +88,7 @@ description: 在正式规划前固化作品根设定,并按前三章、前十
| 前五十章 | 完成初期主冲突与阶段高潮,让主角获得下一阶段资格 | 回收早期承诺,引入中期入口;不能把全书核心一次讲尽 |
| 全书阶段 | 逐级扩大个人、组织、战争与文明尺度 | 每次只揭开下一阶段必需的一层真相,并保留后续问题 |
-每项关键设定都要同时写清“作者掌握的总设定”和“读者在各阶段看到什么”。终局真相可以在作者侧完整存在,正文披露点必须服从根设定,不能因为候选写得完整就在前三章泄底。
+每项关键设定都要同时区分三条信息线:世界实际是什么、角色当前知道什么、读者当前看到什么。终局真相可以在作者侧完整存在,正文披露点必须服从根设定,不能因为候选写得完整就在前三章泄底。
## 参考书证据
@@ -66,15 +103,15 @@ description: 在正式规划前固化作品根设定,并按前三章、前十
## 冻结候选合同
-派发前由主会话一次性冻结候选结构,所有候选使用完全相同的二级标题、顺序和编号号段。结构冻结后,子代理不得自行增删章节或改号段。
+派发前由主会话一次性冻结候选结构。所有候选使用完全相同的三级标题、顺序和编号号段。结构冻结后,子代理不得自行增删章节或改号段。
-- 每章按内容写 10—50 项设定,不固定写 10 项,也不为凑上限拆碎同一规则。
-- 每份候选不少于 100 项设定;当前任务另有字数要求时同时执行,未明说时不得私自降低已冻结门槛。
-- 当前长篇设定候选的默认有效字符下限为 50000;用户明确修改时以任务包为准。
-- 每项使用唯一 `S` 编号,编号必须落在本章预留号段内,且在全文中递增、不重复。
-- 候选只能用被冻结的两级目录;具体机制、例子和剧情用途写在设定项正文里。
+- `S` 编号全文连续、唯一、递增;号段按一级章节预留,三级章节不再单独分号段。
+- 每条 `S` 必须位于一个三级标题下;一个三级章节可以有一条或多条 `S`。
+- 每个三级章节开头写四字段内容说明(回答/应写/不写/关联),再放 `S` 条目或不适用标注。
+- 默认不设每项数量、总项数和字数门槛;任务包显式指定时以任务包为准,未指定时不得私自补加,也不得借门槛灌水。
+- 完成与否以结构完成判定为准,见 [候选生成合同](references/candidate-contract.md) 的完成判定节。
-同构只用于比较,不要求五份候选得出相同答案。每份方案必须有自己的故事发动机、阶段冲突、能力成本、关系推进和舞台扩张路径。
+同构只用于比较,不要求多份候选得出相同答案。每份方案必须有自己的故事发动机、阶段冲突、能力成本、关系推进和舞台扩张路径。
## 独立生成
@@ -82,11 +119,11 @@ description: 在正式规划前固化作品根设定,并按前三章、前十
1. 每个子代理只负责一份候选,只能读取共同输入和自己的工作文件。
2. 候选之间不共享草稿、提纲、评价和中间结论;文件隔离由工作目录或沙箱保证,不能只靠提示词提醒。
-3. 长文按冻结目录逐章续写,同一候选沿用自己的设计账本;续跑仍不能读取兄弟候选。
+3. 长文按冻结三级树逐章续写,同一候选沿用自己的设计账本;续跑仍不能读取兄弟候选。
4. 子代理先在内部检查设定咬合,再落完整条目;主会话不替它补创意,只做编排和机械验证。
5. 任一候选未达到合同,不得先拿已完成候选做综合,避免后写方案被前案污染。
-受控 Claude 调用遵守 `execute-claude-task` 的 fresh process、沙箱、期限和模型要求,并由 `record-run-evidence` 保存回执。模型超时或截断时保留该候选已完成的合法章节,从最后一个完整设定项继续;禁止用空话补足字数。
+受控 Claude 调用遵守 `execute-claude-task` 的 fresh process、沙箱、期限和模型要求,并由 `record-run-evidence` 保存回执。模型超时或截断时保留该候选已完成的合法章节,从最后一个完整内容单元继续;禁止用空话补足篇幅。
## 去 AI 味与语义复核
@@ -96,7 +133,8 @@ description: 在正式规划前固化作品根设定,并按前三章、前十
- 根设定是否全部兑现,是否暗加用户未授权的硬设定。
- 前三章、前十章、前五十章是否各有目标、兑现、代价和新问题。
-- 作者总设定与读者阶段认知是否分开,是否提前泄露终局答案。
+- 世界事实、角色知情、读者披露三条线是否分开,是否提前泄露终局答案。
+- 每个核心实体是否有稳定定义和阶段状态;每个阶段是否有进入条件、状态矩阵和退出条件。
- 能力、资源、等级、敌人和组织是否互相咬合,有无无成本万能解。
- 设定能否通过事件、差异和后果呈现,是否只能靠旁白说明。
- 参考作品是否只借了方法,是否换名照搬了专属骨架。
@@ -108,11 +146,11 @@ description: 在正式规划前固化作品根设定,并按前三章、前十
```bash
.venv/bin/python .claude/skills/design-story-foundation/scripts/validate_candidates.py \
--root-doc docs/design/<作品>-前期设计.md \
- --min-candidates 5 --min-chars 50000 \
+ --min-candidates 5 \
docs/design/candidates/*.md
```
-校验器检查根设定句长和结构、候选目录一致性、号段、编号唯一性、每章 10—50 项、总项数、有效字符数和占位符。任一文件失败,整组状态为 `SETTING_INIT_VALIDATION_FAILED`,不得声称候选齐备。
+校验器只检查机械易错且人眼难以抽查的项:三级标题树与 `scripts/candidate_tree.json` 同构、号段与 `S` 编号唯一递增不越界、占位符残留、根设定句长与格式。它不检查数量、字数或内容质量;内容是否完整由语义复核按完成判定逐项核对,机械绿不等于完成。任一文件失败,整组状态为 `SETTING_INIT_VALIDATION_FAILED`,不得声称候选齐备。其中**章节名(三级标题树)同构是常驻硬门禁**,不接受任务包或临时指令放宽;其余检查项随合同版本调整。
## 用户选择与交接
@@ -120,9 +158,9 @@ description: 在正式规划前固化作品根设定,并按前三章、前十
只有用户明确选择或给出修改方向后,才开始收敛。用户可以直接指定主案,也可以要求把多份候选逐章统合;后一种方式必须遵守 [串行统合合同](references/serial-merge-contract.md),不能一次性把所有章节交给同一代理拼接。
-逐章统合时,每一章使用一个 fresh 高推理代理。第一个代理只比较五份候选的第一章;主会话审定并写入统合候选后,第二个代理必须读取最新统合前文,再比较五份候选的第二章。此后依次推进,权威顺序固定为“最新根设定 > 已统合前文 > 当前五份来源章节”。后章不得推翻前章已经确定的因果、人物关系、数值、专名和披露节点。
+逐章统合以一级章节为单位,每一章使用一个 fresh 高推理代理。第一个代理只比较来源候选的第一章;主会话审定并写入统合候选后,第二个代理必须读取最新统合前文,再比较来源候选的第二章。此后依次推进,权威顺序固定为“最新根设定 > 已统合前文 > 当前来源章节”。后章不得推翻前章已经确定的因果、人物关系、数值、专名和披露节点。
-统合代理负责筛选、识别冲突和提出当前章草案,主会话负责消歧、定名、补齐根设定和最终落文档。不能按票数机械取多数,也不能把五案互斥的发动机全部叠加。每章落盘后先检查结构与语义,再生成下一章任务包;失败时停在当前章,不得让后续代理基于未审定草案继续。
+统合代理负责筛选、识别冲突和提出当前章草案,主会话负责消歧、定名、补齐根设定和最终落文档。不能按票数机械取多数,也不能把多案互斥的发动机全部叠加。每章落盘后先检查结构与语义,再生成下一章任务包;失败时停在当前章,不得让后续代理基于未审定草案继续。
统合完成后仍是待选候选,不自动进入唯一前期设计 SoT。用户确认统合方向后,才把内容整理回 SoT;其余候选删除,由 Git 历史追溯。用户明确说“前期设计定稿”后,才把选定方案交给 `plan-story`,按正式 schema 生成 Shadow 规划。
@@ -133,4 +171,4 @@ description: 在正式规划前固化作品根设定,并按前三章、前十
- 数据库:不读不写任何表;这些文件是产品链之外的用户协作草稿,在系统视角中不是正式内容。
- Git:不执行 `add`、`commit` 或删除候选,除非用户对相应动作另行明确授权。
-缺根设定、对话冲突未标出、参考范围不完整、候选之间发生污染或机械门失败时,返回稳定失败码并停在候选阶段:`SETTING_INIT_INPUT_INCOMPLETE`、`SETTING_INIT_CONTRACT_DRIFT`、`SETTING_INIT_CANDIDATE_CONTAMINATED` 或 `SETTING_INIT_VALIDATION_FAILED`。不得用部分结果冒充完成。
+缺根设定、对话冲突未标出、参考范围不完整、候选之间发生污染、候选结构偏离冻结三级树或机械门失败时,返回稳定失败码并停在候选阶段:`SETTING_INIT_INPUT_INCOMPLETE`、`SETTING_INIT_CONTRACT_DRIFT`、`SETTING_INIT_CANDIDATE_CONTAMINATED` 或 `SETTING_INIT_VALIDATION_FAILED`。不得用部分结果冒充完成。
diff --git a/.claude/skills/design-story-foundation/references/candidate-contract.md b/.claude/skills/design-story-foundation/references/candidate-contract.md
index de787da..bdd501f 100644
--- a/.claude/skills/design-story-foundation/references/candidate-contract.md
+++ b/.claude/skills/design-story-foundation/references/candidate-contract.md
@@ -1,6 +1,6 @@
-# 候选生成合同(design-story-foundation/v1)
+# 候选生成合同(design-story-foundation/v2)
-本合同在派发作品设定候选前读取。它定义共同输入、候选结构和复核口径,不保存任何具体作品设定。
+本合同在派发作品设定候选前读取。它定义共同输入、候选结构、层级编号和完成判定,不保存任何具体作品设定。各章节“应该写什么”的内容合同见 [章节内容规范](chapter-content-spec.md)。
## 一、冻结任务包
@@ -8,7 +8,7 @@
| 字段 | 内容 |
|---|---|
-| `contractVersion` | 固定为 `design-story-foundation/v1` |
+| `contractVersion` | 固定为 `design-story-foundation/v2` |
| `workTitle` | 当前作品名 |
| `sotPath` | 唯一前期设计文档路径 |
| `rootSetting` | 根设定全文,不用摘要替代 |
@@ -16,10 +16,11 @@
| `decisionLedger` | 每项标为已接受、已否决、待定或被新要求覆盖 |
| `unresolvedQuestions` | 会实质改变故事方向、仍需用户拍板的问题 |
| `retentionMilestones` | 前三章、前十章、前五十章及后续阶段落点 |
-| `disclosureRules` | 作者总设定与读者阶段认知的分界 |
+| `disclosureRules` | 世界事实、角色知情、读者披露三线分界 |
+| `phaseRequirements` | 用户指定的阶段模型;为空时由候选自行划分并写明边界依据 |
| `referenceEvidence` | 大纲直证、正文归纳、可借鉴、不可照搬四栏 |
-| `sectionContract` | 统一二级标题、顺序、预留编号号段 |
-| `quantityContract` | 每章 10—50 项、全文至少 100 项、有效字符下限 |
+| `sectionContract` | 固定九章三级树(引用 chapter-content-spec),含各一级章节预留号段 |
+| `thresholdOverrides` | 用户显式指定的数量或字数门槛;机械门不查数量,为空时不设任何数量要求 |
| `candidateId` | 当前子代理唯一编号 |
| `outputPath` | 当前子代理唯一输出文件 |
@@ -27,28 +28,58 @@
## 二、统一文档形状
-每份候选只允许一个一级标题。正文使用任务包冻结的二级标题,不增设三级标题。每个内容章紧跟一个号段标记:
+每份候选只允许一个一级标题。`## 目录`可选,不参与号段和判定。正文使用固定的二级、三级标题,顺序逐字一致:
```markdown
-## 一、示例章节
+# 候选标题
+
+## 一、作品定位与核心命题
+### 1.1 作品形态与定位
+
+> 回答:这是什么样的作品、写给谁、以什么形态连载、与同类有何差别。
+> 应写:题材标签、载体与读者、篇幅与连载结构、差异化定位。
+> 不写:故事内部设定、情节、角色。
+> 关联:1.2、1.4、6.2。
+
+### 1.1.1 题材、载体与目标读者
+
+> 回答:题材元素、发布载体与目标读者分别是什么。
+> 应写:题材标签、载体与阅读场景、读者群体与其期待。
+> 不写:篇幅结构归 1.1.2,差异比较归 1.1.3。
+> 关联:1.1.2、1.1.3。
+
- **S001|设定名称:** 设定正文。
+
+### 1.1.2 篇幅、连载方式与结构规模
+
+> 不适用:本作品尚未确定连载形态,待用户确认后补充。
```
-目录名称、顺序、号段必须逐字一致。候选序号可以出现在一级标题,不能进入共同目录。每章实际使用 10—50 个编号;空号允许,越界、倒序和重复不允许。
+规则:
-一项设定可以写多段,但必须围绕同一规则。正文自然交代以下内容,不使用整齐划一的表单腔:
+1. 四字段说明中“回答、应写、关联”必填,“不写”用于标出最常见的越界写法;内容以 chapter-content-spec 为准,子代理不得自行改写合同语义。
+2. 每个三级章节要么至少一条 `S` 条目,要么一条“不适用及原因”标注;两者不得同时缺省。
+3. 一项 `S` 可以写多段并包含表格,但必须围绕同一内容单元;阶段状态矩阵、伏笔登记、时间线核对允许以表格为主体。
-- 规则是什么,在什么条件下生效。
-- 谁因此获利,谁承担成本,失控时会发生什么。
-- 它会在哪个阶段进入故事,通过什么事件让读者看见。
-- 它怎样连接角色关系、资源压力、冲突或下一阶段入口。
+## 三、层级与编号规则
-达到篇幅门槛靠机制、差异、案例、后果和边界,不靠同义改写、套话、总结段或重复背景。
+```text
+一级标题:一个创作设计领域
+二级标题:这个领域要回答的一组问题
+三级标题:可以独立填写、审查和引用的内容单元
+S 条目:三级标题下的具体设定、规则、实体、关系、阶段状态或事件
+```
-## 三、节奏与披露
+1. 号段按一级章节预留,写在章标题之后;三级章节不单独分号段,避免号段过碎。
+2. `S` 编号全文连续、唯一、递增,必须落在本章号段内;空号允许,越界、倒序和重复不允许。
+3. 禁止四级标题;禁止在二级标题下直接放 `S` 条目。
+4. 阶段名称(初期、中期、后期或卷、篇、幕)只能出现在卡片正文、表格列和第五章中,不得成为二、三级标题。
+5. 所有候选标题逐字同构;候选序号只出现在一级标题,不进入共同目录。
+
+## 四、节奏与披露
候选先完成追读链,再扩写设定。三个早期里程碑都要同时具备“当期问题、行动目标、实际兑现、付出代价、章末新问题”。
@@ -57,28 +88,48 @@
- 前五十章完成初期主冲突和一次身份或能力抬升,再打开更大舞台。
- 后续阶段继续扩大问题尺度;早期劳动、训练、资源或关系线要换规模延续,不能用完即丢。
-总设定写作者掌握的真相;阶段设定写读者当时能确认的事实。一个谜底可以在作者侧确定,但它的征兆、误判、局部解释和正式揭示必须分开放置。
+信息分三线记录:世界实际状态、角色知道的状态、读者已看到的状态。一个谜底可以在作者侧确定,但它的征兆、误判、局部解释和正式揭示必须分开放置,归口见 chapter-content-spec 第七章。
-## 四、独立性
+## 五、独立性
候选子代理只能读取任务包、共同参考证据和自己的文件。不得搜索候选目录,不得读取其他候选,不得询问主会话“前一份怎么写”。主会话也不能把某份候选的优点转述给尚未完成的代理。
长文需要多轮时,在同一候选内部维护简短设计账本:已经确定的因果、数值、人物关系、阶段披露点和未完成章节。账本只属于该候选,续跑时与任务包一起提供。
-## 五、表达复核
+## 六、表达复核
完成内容复核后再处理语言。表达层重点清理:
- 空泛评价代替具体事件,例如只说“极具张力”“层层递进”。
- 每段都用“不是……而是……”或三项排比制造伪力度。
-- 所有设定项使用相同句式、相同结尾或固定总结句。
+- 所有条目使用相同句式、相同结尾或固定总结句。
- 频繁使用“同时、此外、值得注意的是、总而言之”等连接词。
- 用旁白宣布人物多强、世界多危险,却没有任务、损失和对比支撑。
- 为了显得完整,提前解释终局真相或最终敌人的全貌。
复核可以改句子长短、用词和段落节奏,不能改事实、编号、目录、量级、代价或披露节点。
-## 六、交付报告
+## 七、完成判定
+
+一份候选只有同时满足以下条件才算完成。机械可查项由校验器把关,其余由语义复核逐项核对:
+
+1. 九个一级章节齐全,全部二级、三级标题齐全且顺序一致。
+2. 每个三级章节有内容说明,并有 `S` 条目或显式不适用标注。
+3. 每个核心实体都有稳定定义和阶段状态。
+4. 每个阶段都有进入条件、核心问题、状态矩阵和退出条件。
+5. 每个阶段转变都有事件或明确原因。
+6. 主要关系都有初始状态、变化触发和后续状态。
+7. 主要规则都有触发、边界、代价和可观察结果。
+8. 主要事件都改变至少一项状态或推进一项承诺。
+9. 世界事实、角色知情和读者披露三条线没有混写。
+10. 前三章、前十章、前五十章分别有目标、兑现、代价和新问题。
+11. 没有把终局答案提前泄露给不该知道的角色或读者。
+12. 没有重复定义同一事实,也没有同名异义。
+13. 数字、单位、术语和时间线一致。
+14. 候选之间结构相同,但故事方案可以不同。
+15. 任务包指定了数量或字数门槛时同时满足;未指定时不以篇幅论完成。
+
+## 八、交付报告
所有候选过门后才生成比较报告。报告逐案回答:
@@ -86,12 +137,13 @@
2. 前十章兑现了什么,暴露了什么限制。
3. 前五十章在哪个事件形成初期高潮。
4. 初期机制如何换规模进入中期和后期。
-5. 这套方案最大的收益、代价和失速风险是什么。
+5. 阶段状态矩阵是否闭合,转段事件是否成立。
+6. 这套方案最大的收益、代价和失速风险是什么。
报告只供用户选择,不宣告胜者。用户没有拍板前,不合并、不落库、不写正文。
-## 七、用户授权后的逐章统合
+## 九、用户授权后的逐章统合
-用户明确要求综合多份候选时,独立生成阶段结束,进入串行统合阶段。统合不再要求来源隔离,但每个代理仍只处理一个当前章节,不能提前读取后续来源章节。详细输入、权威顺序、输出格式与失败边界见 [串行统合合同](serial-merge-contract.md)。
+用户明确要求综合多份候选时,独立生成阶段结束,进入串行统合阶段。统合以一级章节为节点,代理必须读取该章完整的三级结构。统合不再要求来源隔离,但每个代理仍只处理一个当前章节,不能提前读取后续来源章节。详细输入、权威顺序、输出格式与失败边界见 [串行统合合同](serial-merge-contract.md)。
串行链的每个节点都要生成 fresh 会话。当前章只有经主会话审定并写入统合候选后,才可作为下一节点的权威前文。未审定的代理输出、分析摘要和舍弃方案不能进入下一节点上下文。
diff --git a/.claude/skills/design-story-foundation/references/serial-merge-contract.md b/.claude/skills/design-story-foundation/references/serial-merge-contract.md
index c796a58..f0180d4 100644
--- a/.claude/skills/design-story-foundation/references/serial-merge-contract.md
+++ b/.claude/skills/design-story-foundation/references/serial-merge-contract.md
@@ -1,10 +1,10 @@
-# 候选逐章串行统合合同(design-story-foundation/serial-merge-v1)
+# 候选逐章串行统合合同(design-story-foundation/serial-merge-v2)
-本合同用于用户明确要求综合多份已完成候选的场景。它只生成一份新的统合候选,不直接修改作品前期设计 SoT,不写正文,不落数据库。
+本合同用于用户明确要求综合多份已完成候选的场景。它只生成一份新的统合候选,不直接修改作品前期设计 SoT,不写正文,不落数据库。统合以一级章节为节点;每章含完整三级结构,内容合同见 [章节内容规范](chapter-content-spec.md)。
## 一、串行拓扑
-统合严格按冻结目录从前往后执行,一章一个 fresh 高推理代理。当前章审定落盘之前,不启动下一章。每个节点只接收三类作品内容:最新根设定全文、统合候选已经完成的已统合前文、所有来源候选的当前章节。
+统合严格按冻结三级树从前往后执行,一个一级章节一个 fresh 高推理代理。当前章审定落盘之前,不启动下一章。每个节点只接收三类作品内容:最新根设定全文、统合候选已经完成的已统合前文、所有来源候选的当前章节(含全部二级、三级标题与条目)。
禁止把来源候选的后续章节提前交给当前代理。禁止复用上一节点会话,防止舍弃方案和未审定分析越过主会话进入后章。代理输出只是建议;主会话完成冲突检查、必要改写和落盘后,落盘版本才成为下一节点输入。
@@ -14,7 +14,7 @@
1. 用户最新确认的根设定。
2. 统合候选已经审定落盘的前文章节。
-3. 当前五份来源章节中与前两项兼容的高质量设定。
+3. 当前来源章节中与前两项兼容的高质量设定。
4. 为补齐因果所需的最小新增连接内容。
来源候选没有投票权。三份写法相同也不能压过根设定,一份写法更完整也可以被采用。当前章可以合并同方向条目、删去重复条目、改写名称与数值以消除冲突,但不能偷偷更换已经确定的故事发动机。
@@ -23,9 +23,9 @@
优先保留能直接产生剧情、代价、差异和后续接口的设定。单纯正确但没有故事用途的百科说明降级;只靠旁白成立的强度宣告降级;与前文重复、只换说法的条目删除。
-每个保留项至少完成两件事:说清规则或事实;说明它如何通过事件、人物选择、资源损失、对手反应或阶段兑现进入故事。涉及底牌时要同步写清读者在当前阶段能知道的边界。
+每个保留项至少完成两件事:说清规则或事实;说明它如何通过事件、人物选择、资源损失、对手反应或阶段兑现进入故事。涉及底牌时要同步写清读者在当前阶段能知道的边界。阶段状态只保留一份:实体卡内写与自身相关的阶段字段,跨实体汇总进第五章矩阵,不得两处各写一套。
-不把五案的互斥卖点全部叠加。故事只能有一条主发动机,其他候选的优点只能作为服务主线的机制、角色资产或事件结构进入。新增内容以补缝为限,不另造第六套世界观。
+不把多案的互斥卖点全部叠加。故事只能有一条主发动机,其他候选的优点只能作为服务主线的机制、角色资产或事件结构进入。新增内容以补缝为限,不另造第六套世界观。
## 四、当前章输出
@@ -36,22 +36,32 @@
主轴、主要取舍、发现的前文冲突及处理方式。
-## 冻结的当前章标题
+## 冻结的当前一级章标题
+
+
+### x.y 冻结的二级标题
+
+> 回答:… 应写:… 不写:… 关联:…
+
+### x.y.z 冻结的三级标题
+
+> 回答:… 应写:… 不写:… 关联:…
+
- **S001|设定名:** 设定正文。
```
-章节只使用一个二级标题,不增设三级标题。编号必须在冻结号段内递增且不重复,每章 10—50 项。篇幅靠规则、事件、代价和边界获得,不靠同义扩写。不得在正文候选中记录代理、模型、统合过程或来源票数。
+章节必须完整保留冻结的二级、三级标题与顺序,不增设四级标题,不删并三级章节。无适用内容的三级章节写“不适用及原因”。编号必须在本章号段内递增且不重复。篇幅靠规则、事件、代价和边界获得,不靠同义扩写;数量或字数仅在任务包指定时作为约束。不得在正文候选中记录代理、模型、统合过程或来源票数。
## 五、主会话审定
-主会话逐项检查根设定覆盖、与前文的名称和数值一致性、因果闭合、阶段披露、故事负荷及 AI 模板腔。发现冲突时以最小改动修正当前章,不能为了保留当前好点子反向改掉已审定前文;确实需要改前文时必须停下来向用户说明,而不是自行回写。
+主会话逐项检查根设定覆盖、与前文的名称和数值一致性、因果闭合、三线披露(世界事实、角色知情、读者披露)、阶段状态归属、故事负荷及 AI 模板腔。发现冲突时以最小改动修正当前章,不能为了保留当前好点子反向改掉已审定前文;确实需要改前文时必须停下来向用户说明,而不是自行回写。
机械结构通过、语义冲突清零后,主会话才将 `` 内容写入统合候选。`` 留在临时审计文件,不进入作品文档。下一节点读取的是落盘后的完整统合候选,不读取原始输出。
## 六、完成条件
-全部章节完成后,统合候选必须通过与来源候选相同的目录、号段、数量、篇幅和占位符门禁,再进行一次全篇交叉检查。重点检查早期承诺是否在后章换尺度延续,人物与组织是否串位,能力成本是否被后章绕开,以及新增宇宙格局是否遵守既定冲突升级顺序。
+全部章节完成后,统合候选必须通过与来源候选相同的机械门(三级标题树同构、号段、编号唯一递增、占位符残留),任务包如指定数量或字数门槛由主会话人工核对,再按 candidate-contract 的完成判定做一次全篇语义复核。重点检查早期承诺是否在后章换尺度延续,人物与组织是否串位,能力成本是否被后章绕开,阶段矩阵与实体卡阶段字段是否互相一致,以及新增宇宙格局是否遵守既定冲突升级顺序。
-统合完成仍不等于用户定稿。未经用户确认,不合并进前期设计 SoT,不删除五份来源候选,不进入 `plan-story`。
+统合完成仍不等于用户定稿。未经用户确认,不合并进前期设计 SoT,不删除来源候选,不进入 `plan-story`。
diff --git a/.claude/skills/design-story-foundation/scripts/serial_merge.py b/.claude/skills/design-story-foundation/scripts/serial_merge.py
index 0c79d04..36c7363 100644
--- a/.claude/skills/design-story-foundation/scripts/serial_merge.py
+++ b/.claude/skills/design-story-foundation/scripts/serial_merge.py
@@ -12,7 +12,7 @@ import sys
H2_RE = re.compile(r"^##\s+(.+?)\s*$", re.MULTILINE)
-H3_RE = re.compile(r"^#{3,6}\s+", re.MULTILINE)
+DEEP_HEADING_RE = re.compile(r"^#{4,6}\s+", re.MULTILINE)
RANGE_RE = re.compile(r"")
SETTING_RE = re.compile(r"^\s*[-*]\s+(?:\*\*)?(S\d{3,})(?=[^\d])", re.MULTILINE)
CHAPTER_RE = re.compile(r"\s*(.*?)\s*", re.DOTALL)
@@ -120,10 +120,16 @@ def build_packet(args: argparse.Namespace) -> None:
def run_luna(args: argparse.Namespace) -> None:
+ requirements = []
+ if args.min_settings is not None and args.max_settings is not None:
+ requirements.append(f"选择并重写 {args.min_settings}—{args.max_settings} 项高质量设定")
+ if args.min_chars is not None:
+ requirements.append(f"章节有效字符不少于 {args.min_chars}")
+ requirement_sentence = f"{','.join(requirements)}。" if requirements else ""
prompt = (
f"你是当前第 {args.index} 章的独立串行统合代理。完整阅读合同与输入包,"
- f"只处理“{args.heading}”。选择并重写 {args.min_settings}—{args.max_settings} 项高质量设定,"
- f"章节有效字符不少于 {args.min_chars}。严格遵守根设定与已统合前文,不读取或猜测后续章节。"
+ f"只处理“{args.heading}”。{requirement_sentence}"
+ "严格遵守根设定与已统合前文,不读取或猜测后续章节。"
"输出必须且只能包含 与 两个区块,不加代码围栏。"
)
command = [
@@ -171,9 +177,9 @@ def parse_raw(
heading: str,
range_start: int,
range_end: int,
- min_settings: int,
- max_settings: int,
- min_chars: int,
+ min_settings: int | None = None,
+ max_settings: int | None = None,
+ min_chars: int | None = None,
) -> tuple[str, str, list[int]]:
chapter_matches = CHAPTER_RE.findall(raw)
decision_matches = DECISION_RE.findall(raw)
@@ -184,8 +190,8 @@ def parse_raw(
chapter_sections = sections(chapter)
if len(chapter_sections) != 1 or chapter_sections[0].heading != heading:
raise SerialMergeError(f"章节标题必须为“{heading}”且不能出现其他二级标题")
- if H3_RE.search(chapter):
- raise SerialMergeError("章节草案出现三级或更深标题")
+ if DEEP_HEADING_RE.search(chapter):
+ raise SerialMergeError("章节草案出现四级或更深标题")
found_ranges = RANGE_RE.findall(chapter)
normalized_ranges = [(int(start), int(end)) for start, end in found_ranges]
expected_range = (range_start, range_end)
@@ -198,11 +204,11 @@ def parse_raw(
raise SerialMergeError("设定编号必须递增且不重复")
if any(value < range_start or value > range_end for value in ids):
raise SerialMergeError("设定编号越出冻结号段")
- if not min_settings <= len(ids) <= max_settings:
+ if min_settings is not None and max_settings is not None and not min_settings <= len(ids) <= max_settings:
raise SerialMergeError(
f"当前章 {len(ids)} 项,要求 {min_settings}—{max_settings} 项"
)
- if effective_chars(chapter) < min_chars:
+ if min_chars is not None and effective_chars(chapter) < min_chars:
raise SerialMergeError(
f"当前章 {effective_chars(chapter)} 有效字符,低于 {min_chars}"
)
@@ -238,9 +244,9 @@ def shared_output_args(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--heading", required=True)
parser.add_argument("--range-start", type=int, required=True)
parser.add_argument("--range-end", type=int, required=True)
- parser.add_argument("--min-settings", type=int, default=10)
- parser.add_argument("--max-settings", type=int, default=50)
- parser.add_argument("--min-chars", type=int, default=4200)
+ parser.add_argument("--min-settings", type=int, default=None)
+ parser.add_argument("--max-settings", type=int, default=None)
+ parser.add_argument("--min-chars", type=int, default=None)
def parser() -> argparse.ArgumentParser:
@@ -264,9 +270,9 @@ def parser() -> argparse.ArgumentParser:
run.add_argument("--cwd", type=Path, required=True)
run.add_argument("--model", default="catproxy-openai/gpt-5.6-luna")
run.add_argument("--thinking", default="max")
- run.add_argument("--min-settings", type=int, default=12)
- run.add_argument("--max-settings", type=int, default=18)
- run.add_argument("--min-chars", type=int, default=4200)
+ run.add_argument("--min-settings", type=int, default=None)
+ run.add_argument("--max-settings", type=int, default=None)
+ run.add_argument("--min-chars", type=int, default=None)
run.add_argument("--timeout", type=int, default=1200)
run.set_defaults(handler=run_luna)
diff --git a/.claude/skills/design-story-foundation/scripts/test_contract.py b/.claude/skills/design-story-foundation/scripts/test_contract.py
deleted file mode 100644
index c7bb636..0000000
--- a/.claude/skills/design-story-foundation/scripts/test_contract.py
+++ /dev/null
@@ -1,52 +0,0 @@
-#!/usr/bin/env python3
-"""design-story-foundation 与消费者、项目入口的离线合同测试。"""
-
-from pathlib import Path
-import unittest
-
-
-ROOT = Path(__file__).resolve().parents[4]
-SKILL = (ROOT / ".claude/skills/design-story-foundation/SKILL.md").read_text(encoding="utf-8")
-REFERENCE = (ROOT / ".claude/skills/design-story-foundation/references/candidate-contract.md").read_text(encoding="utf-8")
-SERIAL_MERGE = (ROOT / ".claude/skills/design-story-foundation/references/serial-merge-contract.md").read_text(encoding="utf-8")
-PLANNER = (ROOT / ".claude/agents/planner.md").read_text(encoding="utf-8")
-AGENTS = (ROOT / "AGENTS.md").read_text(encoding="utf-8")
-CHAINS = (ROOT / "meta/chains/README.md").read_text(encoding="utf-8")
-
-
-class SettingInitContractTest(unittest.TestCase):
- def test_single_purpose_and_handoff_are_explicit(self) -> None:
- self.assertIn("正式规划前", SKILL)
- self.assertIn("不写正文、不落库、不替用户定稿", SKILL)
- self.assertIn("用户明确说“前期设计定稿”", SKILL)
-
- def test_root_and_rhythm_contracts_are_present(self) -> None:
- for phrase in ("30—60", "前三章", "前十章", "前五十章", "作者掌握的总设定"):
- self.assertIn(phrase, SKILL)
-
- def test_candidate_quantity_is_a_range(self) -> None:
- self.assertIn("10—50", SKILL)
- self.assertIn("不少于 100 项", SKILL)
- self.assertNotIn("每章固定 10 项", SKILL)
-
- def test_independent_generation_contract_is_frozen(self) -> None:
- self.assertIn("candidateId", REFERENCE)
- self.assertIn("只有 `candidateId` 和 `outputPath`", REFERENCE)
- self.assertIn("不得搜索候选目录", REFERENCE)
-
- def test_serial_merge_is_strictly_chapter_ordered(self) -> None:
- self.assertIn("一章一个 fresh 高推理代理", SERIAL_MERGE)
- self.assertIn("最新根设定", SERIAL_MERGE)
- self.assertIn("已统合前文", SERIAL_MERGE)
- self.assertIn("当前章节", SERIAL_MERGE)
- self.assertIn("不能按票数机械取多数", SKILL)
-
- def test_planner_and_inventory_expose_the_skill(self) -> None:
- self.assertIn("`design-story-foundation`", PLANNER)
- self.assertIn("`design-story-foundation`", AGENTS)
- self.assertIn("setting_init 作品设定初始化", CHAINS)
- self.assertIn("候选不落库、不进 Canonical", CHAINS)
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/.claude/skills/design-story-foundation/scripts/test_serial_merge.py b/.claude/skills/design-story-foundation/scripts/test_serial_merge.py
index d957b97..565b0c1 100644
--- a/.claude/skills/design-story-foundation/scripts/test_serial_merge.py
+++ b/.claude/skills/design-story-foundation/scripts/test_serial_merge.py
@@ -82,12 +82,30 @@ class SerialMergeTest(unittest.TestCase):
## 二、世界
+### 2.1 稳定世界规则
+
+> 回答:世界底层法则。
+
- **S051|规则一:** 规则有明确条件、事件用途、失败代价以及后续阶段接口,不靠旁白成立。
- **S052|规则二:** 另一条规则与前文兼容,并通过人物行动和资源损失进入故事。
"""
_, _, ids = parse_raw(raw, "二、世界", 51, 100, 2, 3, 20)
self.assertEqual([51, 52], ids)
+ def test_raw_output_rejects_fourth_level_heading(self) -> None:
+ raw = """保留规则。
+
+## 二、世界
+
+### 2.1 稳定世界规则
+
+#### 不允许的细分
+
+- **S051|规则一:** 规则有明确条件、事件用途、失败代价以及后续阶段接口。
+"""
+ with self.assertRaises(SerialMergeError):
+ parse_raw(raw, "二、世界", 51, 100)
+
if __name__ == "__main__":
unittest.main()
diff --git a/.claude/skills/design-story-foundation/scripts/test_validate_candidates.py b/.claude/skills/design-story-foundation/scripts/test_validate_candidates.py
index f314294..0c236d2 100644
--- a/.claude/skills/design-story-foundation/scripts/test_validate_candidates.py
+++ b/.claude/skills/design-story-foundation/scripts/test_validate_candidates.py
@@ -1,51 +1,52 @@
#!/usr/bin/env python3
-"""前期设计候选校验器的离线测试。"""
+"""前期设计候选校验器的离线测试:只覆盖机械门禁自身的正确性。"""
+import json
from pathlib import Path
import tempfile
import unittest
from validate_candidates import ValidationConfig, validate_candidates, validate_root
+TREE = json.loads((Path(__file__).with_name("candidate_tree.json")).read_text(encoding="utf-8"))
+BLOCK = "> 回答:本节回答什么。\n> 应写:本节写什么。\n> 不写:本节不写什么。\n> 关联:其他节。\n"
+ITEM = "- **S{sid:03d}|条目:** 这是带条件、代价和剧情用途的完整设定内容。"
-def make_candidate(second_heading: str = "二、资源规则", out_of_range: bool = False) -> str:
- first_ids = range(1, 3)
- second_ids = (101, 52) if out_of_range else range(51, 53)
- first = "\n".join(f"- **S{value:03d}|规则:** 这一条有明确条件、代价和剧情用途。" for value in first_ids)
- second = "\n".join(f"- **S{value:03d}|规则:** 这一条有明确条件、代价和剧情用途。" for value in second_ids)
- return f"""# 候选
-## 目录
+def chapter_bands() -> list[tuple[int, int]]:
+ """按每章实际条目数切分号段,保证全局连续编号都落在本章号段内。"""
+ bands = []
+ cursor = 1
+ for chapter in TREE["chapters"]:
+ count = sum(len(section["units"]) for section in chapter["sections"])
+ bands.append((cursor, cursor + count - 1))
+ cursor += count
+ return bands
-- 第一章
-- 第二章
-## 一、故事发动机
+BANDS = chapter_bands()
-
-{first}
-
-## {second_heading}
-
-
-
-{second}
-"""
+def make_candidate() -> str:
+ parts = ["# 候选\n\n## 目录\n\n- 章节列表\n"]
+ counter = 1
+ for index, chapter in enumerate(TREE["chapters"]):
+ start, end = BANDS[index]
+ parts.append(f"## {chapter['heading']}\n\n\n")
+ for section in chapter["sections"]:
+ parts.append(f"### {section['heading']}\n\n{BLOCK}")
+ for unit in section["units"]:
+ parts.append(f"### {unit}\n\n{BLOCK}")
+ parts.append(ITEM.format(sid=counter))
+ counter += 1
+ return "\n".join(parts)
class CandidateValidatorTest(unittest.TestCase):
def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory()
self.root = Path(self.temp_dir.name)
- self.config = ValidationConfig(
- min_candidates=2,
- min_settings=4,
- min_section_settings=2,
- max_section_settings=3,
- min_chars=0,
- min_item_chars=10,
- )
+ self.config = ValidationConfig(min_candidates=2, min_item_chars=10)
def tearDown(self) -> None:
self.temp_dir.cleanup()
@@ -55,27 +56,34 @@ class CandidateValidatorTest(unittest.TestCase):
path.write_text(content, encoding="utf-8")
return path
+ def codes(self, *contents: str) -> set[str]:
+ paths = [self.write(f"c{index}.md", content) for index, content in enumerate(contents)]
+ _, problems = validate_candidates(paths, self.config)
+ return {problem.code for problem in problems}
+
def test_two_same_shape_candidates_pass(self) -> None:
- paths = [self.write("a.md", make_candidate()), self.write("b.md", make_candidate())]
- reports, problems = validate_candidates(paths, self.config)
- self.assertEqual(2, len(reports))
- self.assertEqual([], problems)
+ self.assertEqual(set(), self.codes(make_candidate(), make_candidate()))
- def test_heading_drift_fails_group(self) -> None:
- paths = [
- self.write("a.md", make_candidate()),
- self.write("b.md", make_candidate(second_heading="二、人物关系")),
- ]
- _, problems = validate_candidates(paths, self.config)
- self.assertIn("CANDIDATE_STRUCTURE_MISMATCH", {problem.code for problem in problems})
+ def test_missing_unit_heading_fails(self) -> None:
+ broken = make_candidate().replace("### 1.1.3 同类作品中的差异\n", "")
+ self.assertIn("TREE_MISMATCH", self.codes(make_candidate(), broken))
- def test_out_of_range_and_order_are_rejected(self) -> None:
- paths = [self.write("a.md", make_candidate()), self.write("b.md", make_candidate(out_of_range=True))]
- _, problems = validate_candidates(paths, self.config)
- codes = {problem.code for problem in problems}
- self.assertIn("SETTING_ID_OUT_OF_RANGE", codes)
+ def test_deep_heading_fails(self) -> None:
+ broken = make_candidate().replace(
+ "### 1.1.1 题材、载体与目标读者\n", "### 1.1.1 题材、载体与目标读者\n\n#### 细分\n"
+ )
+ self.assertIn("CANDIDATE_STRUCTURE", self.codes(make_candidate(), broken))
+
+ def test_duplicate_and_out_of_range_ids_fail(self) -> None:
+ broken = make_candidate().replace("S002|条目:", "S001|条目:").replace("S003|条目:", "S002|条目:")
+ codes = self.codes(make_candidate(), broken)
+ self.assertIn("SETTING_ID_DUPLICATE", codes)
self.assertIn("SETTING_ORDER", codes)
+ def test_placeholder_fails(self) -> None:
+ broken = make_candidate().replace("完整设定内容", "TODO")
+ self.assertIn("CANDIDATE_PLACEHOLDER", self.codes(make_candidate(), broken))
+
def test_root_contract_accepts_clean_items(self) -> None:
root_doc = self.write(
"root.md",
@@ -89,8 +97,7 @@ class CandidateValidatorTest(unittest.TestCase):
## 二、定盘
""",
)
- problems = validate_root(root_doc, self.config)
- self.assertEqual([], problems)
+ self.assertEqual([], validate_root(root_doc, self.config))
def test_root_rejects_process_language(self) -> None:
root_doc = self.write(
@@ -104,8 +111,7 @@ class CandidateValidatorTest(unittest.TestCase):
## 二、定盘
""",
)
- problems = validate_root(root_doc, self.config)
- self.assertIn("ROOT_PROCESS_LEAK", {problem.code for problem in problems})
+ self.assertIn("ROOT_PROCESS_LEAK", {problem.code for problem in validate_root(root_doc, self.config)})
if __name__ == "__main__":
diff --git a/.claude/skills/design-story-foundation/scripts/validate_candidates.py b/.claude/skills/design-story-foundation/scripts/validate_candidates.py
index 0223230..fd1b13e 100644
--- a/.claude/skills/design-story-foundation/scripts/validate_candidates.py
+++ b/.claude/skills/design-story-foundation/scripts/validate_candidates.py
@@ -1,5 +1,10 @@
#!/usr/bin/env python3
-"""校验作品设定初始化的根设定与同构候选,不调用模型、不访问数据库。"""
+"""校验作品设定初始化的根设定与同构候选。
+
+只检查机械易错且人眼难以抽查的项:三级标题树同构、S 编号唯一递增不越号段、
+占位符残留、根设定格式与句长。内容是否完整由语义复核按完成判定核对,
+本脚本不做数量、字数或内容质量门槛。
+"""
from __future__ import annotations
@@ -11,26 +16,23 @@ from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Iterable
-
-H2_RE = re.compile(r"^##\s+(.+?)\s*$", re.MULTILINE)
-NESTED_HEADING_RE = re.compile(r"^#{3,6}\s+", re.MULTILINE)
+TREE_PATH = Path(__file__).with_name("candidate_tree.json")
+HEADING_RE = re.compile(r"^(#{1,6})\s+(.+?)\s*$", re.MULTILINE)
RANGE_RE = re.compile(r"")
SETTING_RE = re.compile(r"^\s*[-*]\s+(?:\*\*)?(S\d{3,})(?=[^\d])", re.MULTILINE)
-ROOT_ITEM_RE = re.compile(r"^\s*-\s+\*\*([^*]+)\*\*(.+)$")
PLACEHOLDER_RE = re.compile(
- r"(?:\bTODO\b|\bTBD\b|\[待填\]|待补充|待生成|在此填写|PLACEHOLDER)",
+ r"(? int:
- """按非空白字符计数,避免用空行填充篇幅。"""
- return len(re.sub(r"\s+", "", text))
-
-
def visible_chars(text: str) -> int:
- """去掉常见 Markdown 标记后统计可见字符。"""
+ """去掉常见 Markdown 标记后统计非空白可见字符。"""
cleaned = re.sub(r"[*_`#>\[\]()]", "", text)
- return effective_chars(cleaned)
+ return len(re.sub(r"\s+", "", cleaned))
def _problem(code: str, path: Path, message: str) -> Problem:
return Problem(code=code, file=str(path), message=message)
+def load_tree(path: Path) -> dict:
+ return json.loads(path.read_text(encoding="utf-8"))
+
+
+def expected_heading_sequence(tree: dict) -> list[str]:
+ """按冻结树返回 章→节→单元 的扁平标题序列。"""
+ sequence: list[str] = []
+ for chapter in tree["chapters"]:
+ sequence.append(chapter["heading"])
+ for section in chapter["sections"]:
+ sequence.append(section["heading"])
+ sequence.extend(section["units"])
+ return sequence
+
+
def validate_root(path: Path, config: ValidationConfig) -> list[Problem]:
problems: list[Problem] = []
text = path.read_text(encoding="utf-8")
- headings = list(H2_RE.finditer(text))
+ headings = list(ROOT_H2_RE.finditer(text))
root_index = next((index for index, match in enumerate(headings) if "根设定" in match.group(1)), None)
if root_index is None:
return [_problem("ROOT_SECTION_MISSING", path, "未找到包含“根设定”的二级标题")]
@@ -87,7 +98,7 @@ def validate_root(path: Path, config: ValidationConfig) -> list[Problem]:
end = headings[root_index + 1].start() if root_index + 1 < len(headings) else len(text)
body = text[start:end]
- if NESTED_HEADING_RE.search(body):
+ if ROOT_NESTED_RE.search(body):
problems.append(_problem("ROOT_STRUCTURE", path, "根设定内部出现三级或更深标题"))
for term in PROCESS_TERMS:
@@ -125,38 +136,73 @@ def validate_root(path: Path, config: ValidationConfig) -> list[Problem]:
return problems
-def _parse_candidate(path: Path, config: ValidationConfig) -> tuple[CandidateReport, list[Problem]]:
+def _parse_candidate(path: Path, tree: dict, config: ValidationConfig) -> tuple[CandidateReport, list[Problem]]:
text = path.read_text(encoding="utf-8")
problems: list[Problem] = []
- h2_matches = list(H2_RE.finditer(text))
- headings = tuple(match.group(1).strip() for match in h2_matches)
- if not h2_matches:
- problems.append(_problem("CANDIDATE_STRUCTURE", path, "候选没有二级标题"))
- if NESTED_HEADING_RE.search(text):
- problems.append(_problem("CANDIDATE_STRUCTURE", path, "候选出现三级或更深标题,违反统一两级结构"))
if PLACEHOLDER_RE.search(text):
problems.append(_problem("CANDIDATE_PLACEHOLDER", path, "候选仍含待填占位文字"))
- sections: list[SectionReport] = []
+ headings = [(len(match.group(1)), match.group(2).strip(), match.start(), match.end()) for match in HEADING_RE.finditer(text)]
+ h1_count = sum(1 for level, _, _, _ in headings if level == 1)
+ if h1_count != 1:
+ problems.append(_problem("CANDIDATE_STRUCTURE", path, f"候选应有且只有一个一级标题,实际 {h1_count} 个"))
+ deep = [heading for level, heading, _, _ in headings if level >= 4]
+ if deep:
+ problems.append(_problem("CANDIDATE_STRUCTURE", path, f"候选出现四级或更深标题:{deep[0]}"))
+
+ h2_list = [(heading, start, end) for level, heading, start, end in headings if level == 2]
+ toc_seen = False
+ chapter_index = 0
all_ids: list[int] = []
previous_range_end = 0
+ sections: list[SectionReport] = []
+ chapter_titles = [chapter["heading"] for chapter in tree["chapters"]]
- for index, heading_match in enumerate(h2_matches):
- start = heading_match.end()
- end = h2_matches[index + 1].start() if index + 1 < len(h2_matches) else len(text)
- body = text[start:end]
- ranges = RANGE_RE.findall(body)
- setting_matches = list(SETTING_RE.finditer(body))
+ for position, (heading, start, end) in enumerate(h2_list):
+ body_end = h2_list[position + 1][1] if position + 1 < len(h2_list) else len(text)
+ body = text[end:body_end]
- if not ranges and not setting_matches and heading_match.group(1).strip() == "目录":
+ if heading == "目录":
+ toc_seen = True
+ if re.search(r"^###\s+", body, re.MULTILINE):
+ problems.append(_problem("CANDIDATE_STRUCTURE", path, "目录下不得出现三级标题"))
continue
- if len(ranges) != 1:
+
+ if chapter_index >= len(chapter_titles):
+ problems.append(_problem("TREE_MISMATCH", path, f"多出章节标题:{heading}"))
+ continue
+ expected_title = chapter_titles[chapter_index]
+ if heading != expected_title:
problems.append(
- _problem("SECTION_RANGE", path, f"章节“{heading_match.group(1).strip()}”必须且只能有一个号段标记")
+ _problem("TREE_MISMATCH", path, f"第 {chapter_index + 1} 章应为“{expected_title}”,实际为“{heading}”")
)
+ chapter_index += 1
continue
+ chapter_index += 1
+ expected_subs: list[str] = []
+ for section in tree["chapters"][chapter_index - 1]["sections"]:
+ expected_subs.append(section["heading"])
+ expected_subs.extend(section["units"])
+ actual_subs = [sub_heading for level, sub_heading, sub_start, _ in headings if level == 3 and start < sub_start < body_end]
+ if actual_subs != expected_subs:
+ expected_set = set(expected_subs)
+ missing = [item for item in expected_subs if item not in set(actual_subs)]
+ extra = [item for item in actual_subs if item not in expected_set]
+ detail = []
+ if missing:
+ detail.append(f"缺 {len(missing)} 个(首个:{missing[0]})")
+ if extra:
+ detail.append(f"多 {len(extra)} 个(首个:{extra[0]})")
+ if not detail:
+ detail.append("顺序与冻结树不一致")
+ problems.append(_problem("TREE_MISMATCH", path, f"章节“{heading}”标题树漂移:{';'.join(detail)}"))
+
+ ranges = RANGE_RE.findall(body)
+ if len(ranges) != 1:
+ problems.append(_problem("SECTION_RANGE", path, f"章节“{heading}”必须且只能有一个号段标记"))
+ continue
range_start, range_end = (int(value) for value in ranges[0])
if range_start > range_end:
problems.append(_problem("SECTION_RANGE", path, f"章节号段倒置:S{range_start:03d}-S{range_end:03d}"))
@@ -164,10 +210,9 @@ def _parse_candidate(path: Path, config: ValidationConfig) -> tuple[CandidateRep
problems.append(_problem("SECTION_RANGE", path, "章节号段未按顺序递增或发生重叠"))
previous_range_end = max(previous_range_end, range_end)
+ setting_matches = list(SETTING_RE.finditer(body))
ids = [int(match.group(1)[1:]) for match in setting_matches]
all_ids.extend(ids)
- if ids != sorted(ids):
- problems.append(_problem("SETTING_ORDER", path, f"章节“{heading_match.group(1).strip()}”的编号未递增"))
for setting_id in ids:
if not range_start <= setting_id <= range_end:
problems.append(
@@ -177,17 +222,6 @@ def _parse_candidate(path: Path, config: ValidationConfig) -> tuple[CandidateRep
f"S{setting_id:03d} 不在章节号段 S{range_start:03d}-S{range_end:03d} 内",
)
)
-
- count = len(ids)
- if not config.min_section_settings <= count <= config.max_section_settings:
- problems.append(
- _problem(
- "SECTION_SETTING_COUNT",
- path,
- f"章节“{heading_match.group(1).strip()}”有 {count} 项,要求 {config.min_section_settings}—{config.max_section_settings}",
- )
- )
-
for item_index, setting_match in enumerate(setting_matches):
item_end = setting_matches[item_index + 1].start() if item_index + 1 < len(setting_matches) else len(body)
item_text = body[setting_match.start():item_end]
@@ -195,36 +229,32 @@ def _parse_candidate(path: Path, config: ValidationConfig) -> tuple[CandidateRep
problems.append(
_problem("SETTING_ITEM_TOO_SHORT", path, f"{setting_match.group(1)} 内容过短,疑似只有标题或占位句")
)
-
sections.append(
- SectionReport(
- heading=heading_match.group(1).strip(),
- range_start=range_start,
- range_end=range_end,
- setting_count=count,
+ SectionReport(heading=heading, range_start=range_start, range_end=range_end, setting_count=len(ids))
+ )
+
+ if chapter_index < len(chapter_titles):
+ problems.append(
+ _problem(
+ "TREE_MISMATCH",
+ path,
+ f"缺少章节:{ '、'.join(chapter_titles[chapter_index:]) }",
)
)
+ if toc_seen and chapter_index == 0:
+ problems.append(_problem("CANDIDATE_STRUCTURE", path, "候选只有目录,没有内容章节"))
duplicates = sorted({setting_id for setting_id in all_ids if all_ids.count(setting_id) > 1})
if duplicates:
display = ", ".join(f"S{setting_id:03d}" for setting_id in duplicates[:10])
problems.append(_problem("SETTING_ID_DUPLICATE", path, f"全文编号重复:{display}"))
-
- char_count = effective_chars(text)
- if char_count < config.min_chars:
- problems.append(
- _problem("CANDIDATE_LENGTH", path, f"有效字符 {char_count},低于门槛 {config.min_chars}")
- )
- if len(all_ids) < config.min_settings:
- problems.append(
- _problem("CANDIDATE_SETTING_COUNT", path, f"全文共 {len(all_ids)} 项设定,低于门槛 {config.min_settings}")
- )
+ if any(later <= earlier for earlier, later in zip(all_ids, all_ids[1:])):
+ problems.append(_problem("SETTING_ORDER", path, "全文 S 编号必须严格递增"))
report = CandidateReport(
file=str(path),
- effective_chars=char_count,
setting_count=len(all_ids),
- headings=headings,
+ headings=tuple(heading for heading, _, _ in h2_list),
sections=tuple(sections),
)
return report, problems
@@ -234,6 +264,7 @@ def validate_candidates(
paths: Iterable[Path],
config: ValidationConfig,
root_doc: Path | None = None,
+ tree_path: Path = TREE_PATH,
) -> tuple[list[CandidateReport], list[Problem]]:
candidate_paths = sorted((Path(path) for path in paths), key=lambda item: str(item))
problems: list[Problem] = []
@@ -253,19 +284,18 @@ def validate_candidates(
else:
problems.extend(validate_root(root_doc, config))
+ tree = load_tree(tree_path)
for path in candidate_paths:
if not path.is_file():
problems.append(_problem("CANDIDATE_FILE_MISSING", path, "候选文件不存在"))
continue
- report, file_problems = _parse_candidate(path, config)
+ report, file_problems = _parse_candidate(path, tree, config)
reports.append(report)
problems.extend(file_problems)
- if reports:
+ if len(reports) > 1:
baseline = reports[0]
- baseline_shape = tuple(
- (section.heading, section.range_start, section.range_end) for section in baseline.sections
- )
+ baseline_shape = tuple((section.heading, section.range_start, section.range_end) for section in baseline.sections)
for report in reports[1:]:
shape = tuple((section.heading, section.range_start, section.range_end) for section in report.sections)
if report.headings != baseline.headings or shape != baseline_shape:
@@ -284,13 +314,10 @@ def _build_parser() -> argparse.ArgumentParser:
parser.add_argument("files", nargs="+", type=Path, help="候选 Markdown 文件")
parser.add_argument("--root-doc", type=Path, help="含根设定的唯一前期设计文档")
parser.add_argument("--min-candidates", type=int, default=1)
- parser.add_argument("--min-settings", type=int, default=100)
- parser.add_argument("--min-section-settings", type=int, default=10)
- parser.add_argument("--max-section-settings", type=int, default=50)
- parser.add_argument("--min-chars", type=int, default=50000)
parser.add_argument("--min-item-chars", type=int, default=20)
parser.add_argument("--root-min-chars", type=int, default=30)
parser.add_argument("--root-max-chars", type=int, default=60)
+ parser.add_argument("--tree", type=Path, default=TREE_PATH, help="冻结三级树 JSON")
parser.add_argument("--json", action="store_true", help="输出稳定 JSON")
return parser
@@ -298,22 +325,16 @@ def _build_parser() -> argparse.ArgumentParser:
def main(argv: list[str] | None = None) -> int:
parser = _build_parser()
args = parser.parse_args(argv)
- if args.min_section_settings > args.max_section_settings:
- parser.error("--min-section-settings 不能大于 --max-section-settings")
if args.root_min_chars > args.root_max_chars:
parser.error("--root-min-chars 不能大于 --root-max-chars")
config = ValidationConfig(
min_candidates=args.min_candidates,
- min_settings=args.min_settings,
- min_section_settings=args.min_section_settings,
- max_section_settings=args.max_section_settings,
- min_chars=args.min_chars,
min_item_chars=args.min_item_chars,
root_min_chars=args.root_min_chars,
root_max_chars=args.root_max_chars,
)
- reports, problems = validate_candidates(args.files, config, args.root_doc)
+ reports, problems = validate_candidates(args.files, config, args.root_doc, args.tree)
status = "ok" if not problems else "invalid"
code = "SETTING_INIT_OK" if not problems else "SETTING_INIT_VALIDATION_FAILED"
payload = {
@@ -328,7 +349,7 @@ def main(argv: list[str] | None = None) -> int:
else:
print(f"{code}: {len(reports)} 份候选,{len(problems)} 个问题")
for report in reports:
- print(f"- {report.file}: {report.setting_count} 项,{report.effective_chars} 有效字符")
+ print(f"- {report.file}: {report.setting_count} 项")
for problem in problems:
print(f"[{problem.code}] {problem.file}: {problem.message}")
return 0 if not problems else 1