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