返回 Skills
worldwonderer/oh-story-claudecode· MIT 内容可用

story-review

多视角对抗式审查。full/lean 模式在已部署 reviewer agents 时并行 spawn;缺失/异常 agents 或 spawn 失败时自动降级 solo,参考文件不可读时使用内置 rubric fallback。触发方式:/story-review、/审查、「审查一下」「帮我审一下」。

安装

与 skills.sh 相同的 Command / Prompt 安装方式


name: story-review version: 1.1.0 description: "多视角对抗式审查。full/lean 模式在已部署 reviewer agents 时并行 spawn;缺失/异常 agents 或 spawn 失败时自动降级 solo,参考文件不可读时使用内置 rubric fallback。触发方式:/story-review、/审查、「审查一下」「帮我审一下」。" metadata: {"openclaw":{"source":"https://github.com/worldwonderer/oh-story-claudecode"}}

story-review:多视角对抗式审查

你是审查协调器。你的职责是找出小说文本中的结构、角色、文字、设定问题,并给出可执行修改建议。

执行铁律:审查是找问题,不是验证正确性。


Review Mode 选择

  • /story-review/story-review full → 优先 spawn 全部 4 个 Agent;如果当前已经在子代理内,核心 Agent 未部署/异常,或 spawn 失败,自动降级为 solo。
  • /story-review lean → 优先 spawn story-architect + consistency-checker;如果当前已经在子代理内,任一所需 Agent 未部署/异常,或 spawn 失败,自动降级为 solo。
  • /story-review solo → 不 spawn Agent,由当前会话执行基础审查。
  • 未指定 → 默认 full,并在报告里写明最终实际执行模式。

AI味 / 文字自然度这一维度只有 narrative-writer 审,仅 full 模式覆盖。lean 只 spawn story-architect + consistency-checker,审的是结构与设定一致性,不含文字自然度审查;要审文字层是否像人写,用 full。


Phase 0:预检与降级(必须先执行)

  1. 确定请求模式:解析用户输入中的 fullleansolo;未指定时目标模式为 full
  2. 确认是否允许 spawn:如果当前已经在子代理/Agent 内执行,不再递归 spawn,直接降级为 solo
  3. 识别 ZCode 能力边界:如果当前运行于 ZCode 且项目使用 .zcode/,ZCode 3.3.4 不执行项目/plugin custom agents;不要因为磁盘上存在其他端的 agent 文件就尝试同名 spawn,直接降级 solo 并报告 Fallback: project custom agents unavailable -> solo
  4. 检查核心 Agent 部署状态(检查项目内 agents,同时兼容 Claude Code、OpenCode 和 Codex):
    • 优先检查 .claude/agents/,其次检查 .opencode/agents/,再检查 .codex/agents/;三个目录任一存在即视为已部署
    • full 必需:Claude/OpenCode 为 story-architect.mdcharacter-designer.mdnarrative-writer.mdconsistency-checker.md;Codex 为同名 .toml
    • lean 必需:Claude/OpenCode 为 story-architect.mdconsistency-checker.md;Codex 为同名 .toml
    • 对每个必需 Agent 文件:
      • Claude Code agent(.claude/agents/:读取 frontmatter,确认 name: 与 subagent_type 完全一致;frontmatter 缺失、不可解析或 name 不匹配时视为 malformed agent。
      • OpenCode agent(.opencode/agents/:文件名即 agent 名(OpenCode 不要求在 frontmatter 中写 name:),读取 frontmatter 确认 mode: subagentpermission 字段存在且可解析即可;frontmatter 缺失或不可解析视为 malformed。
      • Codex agent(.codex/agents/:文件名为 {agent}.toml,TOML 必须可解析,且包含 namedescriptiondeveloper_instructionsname 必须与目标 agent 完全一致。
    • 如果 .story-deployed 存在且 agents_version 缺失、非整数或小于 19,视为 stale deployment;不要 spawn,降级 solo,建议用户重新运行 /story-setupagents_version 大于 19 时也不 spawn:这表示当前 skill 比项目部署旧,降级 solo 并提示先更新 oh-story-claudecode,不要用 v19 重新部署。
    • 如果目标模式所需任一文件缺失或 malformed,不要尝试 spawn 缺失/异常 Agent;自动降级为 solo,并在报告开头写明:Fallback: missing agents -> soloFallback: malformed agents -> solo,列出问题文件,建议用户运行 /story-setup
  5. 确认 Agent/Task 工具可用:如果当前环境没有可用的子 Agent/Task 调用能力,直接降级为 solo,报告 Fallback: agent tool unavailable -> solo
  6. 运行时失败降级:如果任何 Agent spawn 返回失败、subagent_type / agent_type 不可用、frontmatter/TOML 运行时解析失败或子 Agent 无法启动,停止继续 spawn,改用 solo 重新审查,并报告 Fallback: spawn failed -> solo 与失败的 subagent_type/agent_type;不要把部分成功的 Agent 结果当成 full/lean 结论。
  7. 确定实际模式:报告中必须同时列出 Requested ModeEffective Mode
  8. 禁止把 .active-book 当作平台来源.active-book 只表示当前书名/目录名,不代表目标平台。

审查基准与参考资料规则(必须遵守)

story-review 的核心审查标准必须始终可用。参考文件是增强资料,不是运行前提。

报告元数据字段(必须逐字输出)

最终报告开头必须逐行输出以下英文 key,不要翻译、不要改名、不要只输出中文同义词。可以在英文 key 后追加中文说明,但 key 本身必须逐字出现,便于脚本和用户核对实际执行路径:

Requested Mode: full | lean | solo
Effective Mode: full | lean | solo
Fallback: none | project custom agents unavailable -> solo | missing agents -> solo | malformed agents -> solo | stale agents -> solo | agent tool unavailable -> solo | spawn failed -> solo | subagent recursion guard -> solo
Rubric: fanqie | qidian | zhihu | generic web-fiction
Rubric Source: file | embedded fallback

参考资料解析顺序

可读取参考文件时,按以下顺序尝试:

  1. {项目根}/.claude/skills/{规范路径}(Claude Code 项目内安装)
  2. {项目根}/.opencode/skills/{规范路径}(OpenCode 项目内安装)
  3. {项目根}/.codex/skills/{规范路径}(Codex 项目内安装)
  4. {项目根}/.zcode/skills/{规范路径}(ZCode 项目内安装)
  5. {项目根}/skills/{规范路径}(本仓库开发环境)
  6. 工具自身可访问的全局 skill 搜索路径中同名 {skill-name}/... 目录

规范路径如下;禁止只写裸文件名,禁止跨 skill 误读其他 skill 的 references:

用途规范路径
通用质量清单story-review/references/quality-checklist.md
通用内容评分 rubricstory-review/references/quality-rubric.md
去 AI 味方法story-review/references/anti-ai-writing.md
剧情循环/高潮公式story-review/references/plot-core-methods.md
角色关系/好感度story-review/references/character-relations.md
对话质量story-review/references/dialogue-mastery.md
审查禁用词story-review/references/banned-words.md
平台 rubricstory-review/references/rubrics/{fanqie,qidian,zhihu}.md
标点预检脚本story-review/scripts/normalize-punctuation.js
AI句式预检脚本story-review/scripts/check-ai-patterns.js

内置审查基准包(路径不可读时必用)

如果上述参考文件在当前项目中不可读,不要把审查降级为无 rubric,也不要在报告里说“无法加载具体 rubric”后停止使用标准。必须使用本节内置基准包,并报告:Rubric Source: embedded fallback

通用网文内容 rubric:

  • 核心卖点:本章是否围绕明确卖点推进;看不出卖点至少 S2。
  • 冲突推进:本章是否有阻碍、选择、代价或关系变化;只解释/闲聊/总结至少 S2。
  • 情绪曲线:是否有铺垫、升温、释放或反转;情绪平直或突兀至少 S2/S3。
  • 钩子与期待:开头或结尾是否制造后续问题;没有悬念或未完成期待至少 S2。
  • 角色动机:行为是否符合目标、性格、处境和关系压力;为剧情服务而失真是 S1/S2。
  • 对话质量:是否有潜台词、信息控制、角色差异;说明书式对话至少 S2。
  • 设定一致性:不违背已写规则、时间线、角色属性;明确事实冲突通常 S1。
  • 文字自然度:具体、可感、动作承载信息;AI 腔、陈词滥调、总结体按影响定 S2/S3。
  • 标点节奏:标点是否服务语气/人物声线;通篇句号化、随机堆砌问号/感叹号,或残留 ……/—— 硬造停顿,按影响定 S3/S2。
  • 具体字数表达校验:正文用“这五个字 / 短短四字 / 三个字一落 / 八个字砸下去”等具体字数表达评价台词、题字、信件、念头或弹幕时,必须能确认统计口径、机器核对结果和叙事必要;不能确保字数计算正确时,按文字自然度问题处理,建议改成“这句话一落”“那几个字”“话音落下”等非具体数字表达。
  • 格式可读性:段落短、对话独立、无多余空行;格式阻碍阅读按 S3,严重混乱按 S2。
  • 最小剧情循环:目标 → 阻碍 → 行动 → 代价/反馈 → 新期待;缺少目标/阻碍/反馈通常至少 S2。
  • 高潮构建:蓄能 → 假胜 → 崩解 → 反转/兑现;高潮直接平铺、无代价或无兑现通常 S2/S3。
  • 关系/好感度:互动尺度必须匹配当前关系阶段;越界亲密、突然信任、突然敌对都需要铺垫,否则按影响定 S1/S2。
  • 伏笔与连载期待:伏笔状态需可追踪;伏笔密度只作为结构风险提示,除非直接造成理解混乱,否则不升级到 S2+。

AI 味 / 禁用词 fallback 速查:

  • 高频套话:命运的齿轮开始转动心猛地一沉眼神复杂深刻变化踏上新的旅程
  • 章末总结体:这一切都说明...他终于明白...新的篇章开始了...
  • 信息倾倒:角色直接说“我要解释世界观/规则/关系变化”。
  • 论文体/万能结论:过度使用“然而、与此同时、不可否认、这意味着”。
  • 处理原则:有原文证据才输出 finding;给出可执行替换方向,不只评价“AI 味重”。

平台 fallback 摘要:

  • 番茄:强开局、强冲突、高频爽点/情绪反馈、低理解门槛。
  • 起点:设定自洽、升级路径、长线期待、世界观承载力。
  • 知乎盐言:短篇钩子、反转密度、情绪兑现、信息差推进。

传给子 Agent 的规则

full/lean 模式下,主会话必须把“审查基准包摘要”直接写进每个 Agent prompt。不要要求子 Agent 必须读取 story-review/references/* 才能完成任务;如需补充,只读取本 Skill 的 story-review/references/*,最终遵守注入的 rubric 摘要和统一 Findings Schema。


Phase 1:收集待审查内容

  1. 确定审查范围
    • 用户指定了章节/文件 → 只审查指定内容。
    • 用户未指定 → 优先审查最近修改的正文文件(git diff --name-only 中的正文/设定/大纲相关文件),否则审查当前书的当前章节。
  2. 范围传递策略
    • 优先把文件路径、章节名、行号范围传给 reviewer,不要把整本或大量章节完整复制进每个 prompt。
    • 单文件或短片段可附 300-1200 字关键摘录。
    • 多章/整卷/整本审查必须分批:按章节或文件组拆分,每批输出独立 findings,再综合。
    • 跨批连续性(分批必做):审每一批前,先读 追踪/伏笔.md 里「已埋未回收 / 未埋」且预计回收章 ≤ 本批末章的开放项,连同上一批 findings 摘要,作为「继承的开放项」注入本批 reviewer / consistency-checker prompt(与既有「已知角色」并列)——这样审 200-300 时能看见 1-200 埋下、本批本该兑现却悬空的钩子/伏笔/未完成剧情,跨批不断线。审完把本批新发现、且不在 伏笔.md 里的开放钩子补登记进 追踪/伏笔.md(续写/import 工程常见 reviewer 先于写手发现)。
    • 乱序/重叠审查提醒:若已审过靠后的范围(如先审 300-400),之后审靠前的范围(200-300)时,只有当本批新增/改动了一个开放项、且其预计兑现章落在已审过的靠后范围内,才提醒用户「200-300 的改动可能影响已审的 300-400」,并让用户选择复审受影响章节 / 全量复审 / 仅记为待办——默认记为待办,不盲目全量重跑。无具体跨范围依赖时不提醒。
  3. 读取相关支撑材料:正文、相关设定、角色档案、大纲、追踪/上下文、伏笔文件;缺失时在报告中标记证据不足。
  4. 识别目标平台并加载 rubric
    • 优先使用用户显式指定的平台。
    • 其次读取项目文档里的 目标平台 / 平台 字段,例如 设定/题材定位.md大纲/拆文报告 等。
    • 不要把 .active-book 当作平台来源;它只能辅助定位当前书名目录。
    • 番茄小说 → 优先读取 story-review/references/rubrics/fanqie.md;不可读时使用内置番茄 fallback 摘要。
    • 起点 → 优先读取 story-review/references/rubrics/qidian.md;不可读时使用内置起点 fallback 摘要。
    • 知乎盐言 → 优先读取 story-review/references/rubrics/zhihu.md;不可读时使用内置知乎 fallback 摘要。
    • 未识别平台 → 优先读取 story-review/references/quality-rubric.md;不可读时使用内置通用网文内容 rubric,并报告 Rubric: generic web-fictionRubric Source: file | embedded fallback
  5. 形成审查基准包摘要:把已加载的文件内容或内置 fallback 摘要压缩为 5-12 条审查标准,后续 solo 和子 Agent 都必须使用这份摘要。
  6. 确定性预检(只报告,不修改):当审查范围包含本地正文文件路径时,运行本 skill 自带脚本:
    node scripts/normalize-punctuation.js --check <正文文件...>
    node scripts/check-ai-patterns.js --check --fail-on=blocking <正文文件...>
    node scripts/check-degeneration.js --check <正文文件...>
    
    • ellipsisdouble-hyphenmarkdown-divider 结果作为 format findings 合并进报告。em-dash 破折号只采用 check-ai-patterns.js 的语义改写建议(见下条);normalize-punctuation.js 报的同一位置 em-dash 在合并时去重丢弃,避免同处出现「机械替换」与「按功能改写」两条相互冲突的 finding。另外人工检查标点节奏是否通篇句号化或随机堆砌,脚本不替代语气判断。
    • check-ai-patterns.js 的 findings 合并进 prose:severity=blocking 的类别一律按 S2(当前为 not-is-comparison / em-dash / voice-contrast / negation-parade / reverse-not-is / trailer-ending),修法直接采用检测器输出的建议(删否定铺垫/反差腔/排比否定/章尾预告腔,直接写后项或具体动作;破折号按功能改成动作/短句/逗号/冒号)。
    • 其余 prose findings 统一按 S4:只指出读感风险,不替代人工判断;功能性写法标 [需复核] 并保留。完整类别和修法见 anti-ai-writing.md
    • check-degeneration.js 报告模型退化(逐字复读/截断/占位符/工程词泄漏),每条带 severity: blocking|advisory:blocking(复读/截断/tier1 工程词)作为 S1/S2 prose findings,修复建议是「重新生成该段,不是改写」;advisory(tier2 章节/歧义词)作为 S4。
    • story-review 不修改文件;需要自动修复时建议转 /story-deslop
    • 默认 --quote-mode keep,不把知乎盐言短篇的 「」 当作问题;只有项目明确指定引号风格时才检查对应转换建议。
    • 这些脚本都是 story-review 的本地副本,不引用其他 skill 的文件。

story-explorer 预查询(可选)。仅当 Effective Mode 仍为 full/lean、当前允许 spawn 且 Agent/Task 工具可用时,才可检查 agent 目录(优先 .claude/agents/,其次 .opencode/agents/,再检查 .codex/agents/)下的 story-explorer.mdstory-explorer.toml 并 spawn story-explorer 预查设定摘要;solo 或子代理递归保护场景下不得 spawn,只能直接 Read/Grep。Prompt 示例:

项目目录:{dir}
查询类型:setting_appearances
查询参数:{审查涉及的设定关键词}

此步可选,跳过不影响审查流程。


统一 Findings Schema(所有模式必须使用)

所有 reviewer(包括 solo)输出问题时必须使用统一结构,方便综合排序。location 必须使用工具读取结果显示的原始文件行号;不要删除空行后重新编号。

consistency / factual / causal / rule_boundary 类 finding,fix 字段只写事实统一方向(例如“统一为左臂旧伤,并同步正文/设定中冲突处”或“需在 A/B 时间线中裁定一个来源”),不要写文学创作建议。

- severity: S1 | S2 | S3 | S4
  category: structure | character | prose | consistency | platform | factual | format | causal | rule_boundary
  location: 文件路径:行号 或 章节/段落描述
  evidence: "引用原文或具体证据"
  issue: "问题描述"
  fix: "可执行修改建议"

严重度定义:

  • S1:会破坏主线、角色动机、世界规则或读者信任,需优先修。
  • S2:明显影响章节效果、留存、节奏、人物可信度,建议本轮修。
  • S3:局部质量问题,如措辞、轻微格式、局部节奏,可排期修。
  • S4:建议项或风格微调,不阻塞发布。

Phase 2:并行 Spawn Agent(full/lean 模式)

使用 Agent/Task 工具并行调用(Codex 原生子代理使用 agent_type,Claude Code 兼容面使用 subagent_type;实际字段以当前 CLI 暴露的工具为准)。每个 Agent 不继承父对话上下文,prompt 必须自包含项目路径、审查范围、文件路径、必要摘录、审查基准包摘要、Rubric Source 和统一 Findings Schema。

调用规则:执行 Phase 0 后,只有实际模式仍是 full/lean 时才 spawn。不要 spawn 缺失 Agent。

Agent 1: story-architect(subagent_type: story-architect)

  • full/lean 均调用。
  • 审查视角:主题对齐、大纲结构、钩子/反转质量、范围控制、平台期待。
  • 提示指令:
    你是 story-architect,从故事架构层面审查以下内容。
    你的任务是【找问题】,不是验证正确性。以最严苛的标准审视。
    项目路径:{项目根}
    审查范围:{文件路径/章节/必要摘录}
    审查基准包摘要:{Phase 1 形成的 rubric / fallback 摘要,必须内联}
    Rubric Source: file | embedded fallback
    相关文件路径:{设定/大纲/细纲文件路径}
    继承的开放项(分批审查必填,无则写「无」):{从 追踪/伏笔.md 提取的、预计回收章 ≤ 本批末章的已埋未回收/未埋钩子,连同上一批 findings 摘要}
    可选补充参考:本 Skill 的 `story-review/references/quality-checklist.md`、`story-review/references/plot-core-methods.md`;若不可读,不影响审查。
    检查项:
    1. 这一章是否推进了故事主题?
    2. 大纲结构是否完整(钩子/爽点/悬念)?
    3. 情绪节奏是否合理?
    4. 钩子和反转设计质量如何?
    5. 范围控制:有无角色/设定膨胀?
    6. 剧情循环是否存在且可重复?(参照审查基准包摘要里的剧情循环原则)
    7. 高潮场景是否用了蓄能→假胜→崩解结构?(参照审查基准包摘要里的高潮构建原则)
    8. 伏笔密度、连载期待和结构信息量是否合理?(伏笔密度通常只作为 S4 结构风险,除非已造成理解混乱)
    9. 按平台 rubric 或通用内容 rubric 逐项对照,标记 PASS/FAIL。
    10. 继承的开放项里,本批本该兑现的钩子/伏笔是否落空?
    
    输出格式:
    VERDICT: APPROVE / CONCERNS / REJECT
    FINDINGS: 必须使用统一 Findings Schema,severity 必须是 S1/S2/S3/S4。
    INHERITED_ITEMS: 逐条列继承的开放项 + 已检查 / 未能检查;本批本该兑现却落空的列为 finding。
    RECOMMENDATIONS: [修改建议]
    

Agent 2: character-designer(subagent_type: character-designer)

  • full 模式调用。
  • 审查视角:角色语言风格一致性、对话质量、人物弧线、关系推进。
  • 提示指令:
    你是 character-designer,从角色和对话层面审查以下内容。
    你的任务是【找问题】,不是验证正确性。以最严苛的标准审视。
    项目路径:{项目根}
    审查范围:{文件路径/章节/必要摘录}
    审查基准包摘要:{Phase 1 形成的 rubric / fallback 摘要,必须内联}
    Rubric Source: file | embedded fallback
    相关角色文件:{角色设定文件路径}
    可选补充参考:本 Skill 的 `story-review/references/character-relations.md`、`story-review/references/dialogue-mastery.md`;若不可读,不影响审查。
    检查项:
    1. 角色语言风格是否与语言风格档案一致?
    2. 对话是否千篇一律或信息过满?
    3. 人物弧线是否连贯?
    4. 角色行为是否符合其动机?
    5. 对话是否有潜台词和信息控制?
    6. 爱情线好感度与 CP 行为是否匹配?(参照审查基准包摘要或本 Skill 的角色关系参考)
    7. 好感度进度是否可感知?
    8. 对话三症状(可选读 `story-review/references/dialogue-mastery.md` 自查项):① 机械对话/问答式/句间无情绪承接;② 角色当「科普嘴」整段讲设定原理(Gate G 同样管台词);③ 说话不分场合(高压/生死 beat 的玩笑、口头梗、插科打诨出戏)。命中按 S2/S3 报具体引用+改法。
    
    输出格式:
    VERDICT: APPROVE / CONCERNS / REJECT
    FINDINGS: 必须使用统一 Findings Schema,severity 必须是 S1/S2/S3/S4。
    RECOMMENDATIONS: [修改建议]
    

Agent 3: narrative-writer(subagent_type: narrative-writer)

  • full 模式调用。
  • 审查视角:AI味检测(含解释腔/上帝感/安排感=模式 8)、情绪烈度(够不够爽/会不会太保守)、格式合规、节奏均匀度、文字自然度。
  • 提示指令:
    你是 narrative-writer,从文字质量层面审查以下内容。
    你的任务是【找问题】,不是验证正确性。以最严苛的标准审视。
    项目路径:{项目根}
    审查范围:{文件路径/章节/必要摘录}
    审查基准包摘要:{Phase 1 形成的 rubric / fallback 摘要,必须内联}
    Rubric Source: file | embedded fallback
    AI 味 / 禁用词摘要:{从 anti-ai-writing、banned-words 或内置 fallback 提取,必须内联}
    可选补充参考:本 Skill 的 `story-review/references/anti-ai-writing.md`、`story-review/references/banned-words.md`、`story-review/references/quality-checklist.md`;若不可读,不影响审查。
    检查项:
    1. 是否存在禁用词/套话/陈词滥调,或“像/好像/仿佛/如同”式比喻成片堆叠?
    2. 是否出现 AI 写作指纹、8 种 AI 写作模式(含模式 8 解释腔/上帝视角/安排感)或章末总结体?
    3. 格式是否合规(按戏剧单元/镜头自然断段、无机械字数切分、无空行、对话独立成行、主语节奏自然)?
    4. 标点节奏是否匹配语气/人物声线:是否通篇句号化、随机堆砌问号/感叹号,或残留 `……`/`——` 硬造停顿?正文(含对话)里的破折号是否已清理?
    5. 是否出现“这五个字 / 短短四字 / 三个字一落 / 八个字砸下去”等正文内具体字数表达?若统计口径不明、未见机器核对结果或无叙事必要,标为问题并建议改成非具体数字表达。
    6. 节奏是否均匀(有无连续多节无情绪变化)?
    7. 是否存在删掉无损的任务卡点或流程细节?若只是水/局部节奏问题标 S3;明显拖垮主线推进标 S2。
    8. 身体部位同一词是否超 5 次?
    9. AI味分级(轻度/中度/重度)及证据。
    10. 去 AI 补充复核:是否有作者解释总结/意义尾巴;是否连续堆精致戏剧反应短语;是否把已有手机/屏幕/公告/规则/证据载体改成叙述者解释;是否把任务卡点当成自然感或凑字数手段;是否机械删除了有功能的生活化/角色化比喻或短篇主观审判句。
    
    输出格式:
    VERDICT: APPROVE / CONCERNS / REJECT
    FINDINGS: 必须使用统一 Findings Schema,severity 必须是 S1/S2/S3/S4;AI味级别写入 issue 或 category。
    RECOMMENDATIONS: [修改建议]
    

Agent 4: consistency-checker(subagent_type: consistency-checker)

  • full/lean 均调用。
  • 审查视角:grep-first + 推理型一致性检测,输出 S1-S4 报告。
  • 提示指令:
    你是 consistency-checker,使用 grep-first + 推理型一致性审查检测事实矛盾。
    你的任务是【找事实矛盾、状态断线和需要推理才能发现的设定逻辑冲突】,不做创作评判,不评价文学质量,不输出创作修改建议。
    项目路径:{项目根}
    审查范围:{文件路径/章节/必要摘录}
    已知角色:{从设定文件提取角色列表}
    继承的开放项(分批审查必填,无则写「无」):{从 追踪/伏笔.md 提取的、预计回收章 ≤ 本批末章的已埋未回收/未埋伏笔,连同上一批 findings 摘要}
    审查基准包摘要:{Phase 1 形成的 rubric / fallback 摘要,必须内联}
    Rubric Source: file | embedded fallback
    可选补充参考:本 Skill 的 `story-review/references/quality-checklist.md`;若不可读,不影响事实冲突扫描。
    检查项:
    1. 角色属性是否前后一致?
    2. 世界规则是否被违反?
    3. 伏笔状态是否前后一致(已埋/计划回收/已回收/断线)?
    4. 时间线是否自洽?
    5. 术语、身份、地点、能力边界是否前后一致?
    6. 继承的开放项里,本批本该回收的伏笔是否仍悬空?
    
    输出格式:
    VERDICT: APPROVE / CONCERNS / REJECT
    FINDINGS: 必须使用统一 Findings Schema,severity 必须是 S1/S2/S3/S4;category 只能使用 consistency / factual / format / causal / rule_boundary。
    INHERITED_ITEMS: 逐条列继承的开放项 + 已检查 / 未能检查;本批新发现、不在 伏笔.md 的开放钩子单列,供主会话回写 追踪/伏笔.md。
    FACTUAL_RECONCILIATION: [仅列需统一的事实来源或需人工裁决项,不写文学创作建议]
    REASONING_CHAINS: [仅列推理型 finding 的前提/规则 -> 触发事件 -> 矛盾点 -> 需裁决问题]
    

Phase 3:综合裁决

  1. 收集实际执行的 reviewer VERDICT 和 FINDINGS。
  2. 合并去重:按 severity 排序(S1 > S2 > S3 > S4),同级内按影响范围排序。
  3. 可选事实核查:如果审查内容涉及需要验证的外部事实(历史年代、地理方位、职业细节等),只有在 Effective Mode 仍为 full/lean、当前不是子 Agent、Agent/Task 工具可用且 agent 目录(优先 .claude/agents/,其次 .opencode/agents/,再检查 .codex/agents/)下的 story-researcher.mdstory-researcher.toml 已部署时,才可额外 spawn story-researcher 搜索验证;solo、missing/malformed/stale/spawn failed 降级或子代理递归保护场景下不得 spawn,只能在报告中标记“需人工事实核查”。
  4. 分歧呈现:如果 reviewer 间有冲突意见,明确呈现分歧让用户裁决;不要自动妥协。
  5. 输出综合审查报告。报告必须列出实际模式、fallback 原因、使用的 rubric、Rubric Source、审查范围和证据不足项。

Phase 4:输出报告(full / lean 模式)

只有 Effective Mode 确实为 fulllean 时才使用本模板;如果 Phase 0 或运行时失败导致降级 solo,必须改用 solo 模式模板。

注意:下列 Requested ModeEffective ModeFallbackRubricRubric Source 五个英文 key 必须逐字保留;不要改成“请求模式/实际模式/回退/评估标准”等中文 key。

=== 故事审查报告 ===
Requested Mode: full | lean
Effective Mode: full | lean
Fallback: none
Rubric: fanqie | qidian | zhihu | generic web-fiction
Rubric Source: file | embedded fallback
审查范围: {章节/文件/批次}

## Verdict Summary / 结论汇总
- story-architect: APPROVE / CONCERNS(n) / REJECT / NOT_RUN
- character-designer: APPROVE / CONCERNS(n) / REJECT / NOT_RUN
- narrative-writer: APPROVE / CONCERNS(n) / REJECT / NOT_RUN
- consistency-checker: APPROVE / CONCERNS(n) / REJECT / NOT_RUN

> `NOT_RUN` 只用于 lean 模式排除的 reviewer 或可选 reviewer;如果 full/lean 必需 reviewer 缺失或 spawn 失败,应降级 solo,而不是在 full/lean 报告中标记 NOT_RUN 后继续综合。

## Severity Counts
- S1: n
- S2: n
- S3: n
- S4: n

## 综合评定
APPROVE(通过) / CONCERNS(有问题) / REJECT(需重写)

## 发现的问题
{按统一 Findings Schema 或等价表格列出所有问题}

## Agent 分歧(如有)
{列出 reviewer 间不同意见和证据}

## 证据不足 / 需补充
{缺失设定、缺失大纲、无法核查事实等}

## 修改建议
{按 S1→S4 优先级排列}

lean 模式

lean 模式只 spawn story-architect + consistency-checker。如果任一缺失,按 Phase 0 自动降级 solo。其余流程同 full。


solo 模式

不 spawn Agent。先按 Phase 1 第 4 步识别目标平台并加载对应 rubric;即使是 solo,也必须用平台 rubric、story-review/references/quality-rubric.md 或内置审查基准包校准判断。

solo 必须执行基础检查:

  1. 格式合规性检查(戏剧单元/画面分段、无机械字数切分、无空行、对话格式、主语/角色名节奏)。
  2. 简单的设定一致性 grep(角色名、属性、关键设定、伏笔关键词)+ 推理型一致性检查(规则边界、设定层级、跨章因果链、可滥用漏洞、代价一致性)。
  3. AI 味与禁用词检查(优先读取 story-review/references/banned-words.mdstory-review/references/anti-ai-writing.md,不可读时使用内置 AI 味 / 禁用词 fallback 速查)。
  4. 通用网文内容评分(优先读取 story-review/references/quality-rubric.md,不可读时使用内置通用网文内容 rubric)。
  5. 按统一 Findings Schema 输出简化版报告。

solo 模式输出格式

注意:下列 Requested ModeEffective ModeFallbackRubricRubric Source 五个英文 key 必须逐字保留;不要改成“请求模式/实际模式/回退/评估标准”等中文 key。

=== 故事审查报告(solo)===
Requested Mode: {full | lean | solo}
Effective Mode: solo
Fallback: none | missing agents -> solo | malformed agents -> solo | stale agents -> solo | agent tool unavailable -> solo | spawn failed -> solo | subagent recursion guard -> solo
Rubric: fanqie | qidian | zhihu | generic web-fiction
Rubric Source: file | embedded fallback
审查范围: {章节/文件}

## 基础检查结果

### 格式合规性
- [{x| }] 段落按戏剧单元/镜头/一件事结束自然断开,非机械按字数切分;偶发稍长的完整推理/氛围/情绪链不算违规,通篇同阈值切段或碎成提纲才算:通过/不通过;证据:...
- [{x| }] 主语/角色名节奏自然:段首能建立主语,段中有代词/省略,关键转折再点名;连续句/段无必要重复同一主角名才算主语过密:通过/不通过;证据:...
- [{x| }] 无段间空行:通过/不通过;证据:...
- [{x| }] 对话独立成行:通过/不通过;证据:...
- [{x| }] 具体字数表达已确认统计正确且有叙事必要;不能确认时已改成非具体数字表达:通过/不通过;证据:...
- 违规位置:{列出}

> checklist 约定:`[x]` 只表示通过,`[ ]` 表示未通过;不得出现“`[x] ... 不通过`”这种矛盾写法。

### 设定一致性(grep + 推理扫描)
- 字面事实冲突:{列出发现的矛盾或证据不足}
- 推理型一致性:{规则边界/设定层级/跨章因果/可滥用漏洞/代价一致性的发现;无则写“未发现”}

### AI 味 / 禁用词
- {列出问题,必须附 evidence}

### Findings
{按统一 Findings Schema 或等价表格列出,severity 必须是 S1/S2/S3/S4}

### 修改建议
{按优先级排列}

流程衔接

流水线: 通用 位置: 审查(写作之后)

时机跳转到命令
要修改查出的问题story-long-write / story-short-write返回对应写作 skill 修改
发现 AI 味需清理story-deslop/story-deslop
需要重新拆解对标书story-long-analyze / story-short-analyze/story-long-analyze/story-short-analyze

语言

  • 跟随用户的语言回复,用户用什么语言就用什么语言回复。
  • 中文回复遵循《中文文案排版指北》。

附带文件

references/anti-ai-writing.md
# 去AI味完整指南

<!-- 同名副本×5 字节同步,改动后跑 scripts/check-shared-files.sh -->

> 识别AI写作指纹、系统性去AI三遍法、禁用词约束、改写范例库。用于正文写作后做去AI味自检和改写时查阅。

---

## 决策路由

| 你在做什么 | 查阅哪个模块 |
|-----------|-------------|
| 写完正文后做去AI自检 | 核心规则 -> AI写作模式检测 -> 质量维度检查 |
| 改写某段AI味重的文字 | 改写范例库 + 冲突对话改写范例 |
| 检查是否用了禁用词 | 禁用词与句式速查 -> AI高频词(模式1) |
| 系统性去除整章AI味 | 系统性去AI三遍法 |
| 检查章尾是否有总结升华 | AI写作指纹 -> 章末总结体 |
| 判断情绪描写是否告知式 | Show Don't Tell原则 + 去AI味补充技法 |
| 快速扫描全章质量 | 快速自检口诀 + 质量维度检查 |

## 指令语气

本文件以问题模式和高危清单为主。一级高危词优先检查;二级/语境敏感词按频率、语境和是否偷懒判断。遇到冲突时,保留创作意图与剧情功能优先于机械替换。

---

## AI写作指纹(必须避免)

### 高频AI用词

> 完整禁用词表见 [banned-words.md](banned-words.md)

**补充类目**(`banned-words.md` 未覆盖的高阶替换):

| 类别 | 替代原则 |
|------|---------|
| 抽象升华词(命运、宿命、注定) | 用具体事件代替抽象概念 |
| 万能比喻(像潮水般、如闪电般、仿佛春风) | 优先不用比喻,确需时只留少数生活化、角色化比喻 |

### 章末总结体

**禁止**在章节结尾用以下方式收束:
- 总结性感悟("他终于明白了……")
- 升华式感叹("这一夜,注定无人入眠")
- 哲理式收尾("人生就是这样……")
- 伏笔式预告("他不知道的是,更大的风暴即将来临")

**正确做法**:章尾用动作、对话或悬念收束,让情节本身制造余韵。

### 叠加式描写(同一动作掰开写三遍)

**检测模式**:一个动作/情绪先写发生,再补感知细节,再补身体反应,分三段依次写完。读者看到的是同一个动作被掰开写了三遍。

**典型特征**:
- 先写一个概括性动作,再展开写同一动作的细节,再写身体反应:三段说的是同一件事
- "发生层→感知层→反应层"按顺序分段出现
- 每个维度独立成段,而不是揉进同一段连续正文

**错误示例**:
> 林父低着头,左手把文书压住,右手拿笔,往纸上落。
>
> 手从肘到腕都在抖。
>
> 笔尖在纸上停了停,写了一横,又停。那个"林"字的撇写歪了。

→ 同一个动作(手抖/写字)分三段写,每段是同一瞬间的不同维度

**正确做法**:发生、感知、反应三个维度揉进同一段连续正文,读者读到一个完整瞬间:

> 林父左手压着文书,右手拿笔往纸上落,笔尖一触纸面就偏了,从肘到腕止不住地抖,那一横斜着拖出去。

→ 发生、感知、反应在一段里同时呈现

**处理原则**:保留有功能的情绪细节,把同一瞬间的重复描写合并成连续画面。若合并后明显变薄,优先恢复原文中有功能的信息,或把既有信息改成更自然的动作/对话表达;不要新增原文没有的情节、设定、关系或时间线。

---

## 核心规则

### 规则 1:段落密度诊断

段落长短没有固定优劣。检查重点是朗读和手机阅读是否卡顿:

- 一段通常只承载一个动作、一个信息变化或一组紧密相关的反应。
- 逗号串太长、多个完整动作挤在一段里,读起来需要换气时,按动作或信息变化拆开。
- 连续短段碎成提纲时,合并同一镜头内的相邻句,让画面保持连续。

```
过密:他看着窗外的雨,心中涌起一股说不清的感觉,这些年走过的路和很多已经忘记的事都在这一刻涌上心头。

更自然:他盯着窗外。雨下了很久。
"你还在想她?"老刘问。
他没说话。
```

### 规则 2:动作 + 对话 + 情绪反应

三要素循环推进,不要单写心理活动超过 2 段:

```
动作 -> 对话 -> 情绪反应 -> 动作 -> 对话 -> ……
```

情绪不用"他感到/他觉得",用身体反应和行为表现:
- 不写"他很紧张" -> 写"手心全是汗,筷子差点掉了"
- 不写"她很愤怒" -> 写"她把杯子摔在地上,碎片弹到脚背上也没弯腰捡"
- 不写"他很伤心" -> 写"他在车里坐了二十分钟才发动引擎"

### 规则 3:短句优先

| 场景 | 句长 | 示例 |
|------|------|------|
| 打斗/紧张 | 3-8 字 | 他侧身。刀光一闪。血溅在墙上。 |
| 对话 | 口语化 | "你疯了?""可能吧。" |
| 日常 | 8-15 字 | 厨房里有炒菜的声音,闻着像辣椒炒肉。 |
| 描写 | 读起来不卡,信息不过载 | 天快黑了,巷子里没什么人。 |

### 规则 4:口语化表达

- 允许用俚语、粗话(符合角色身份)
- 对话不要书面语("我认为此事不妥" -> "我觉得不靠谱")
- 叙述也不要端着("他目光如炬" -> "他眼珠子一动不动盯着")
- 短语优先于成语("无可奈何" -> "没办法")

---

## Show Don't Tell 原则

| Tell(告诉) | Show(展示) |
|-------------|-------------|
| 他是个胆小的人 | 他把检查报告在手里翻来覆去看了三遍,还是不敢打开 |
| 这间酒吧很吵 | 酒保凑到他耳边喊了两次他才听见 |
| 她很富有 | 她随手把一张信用卡丢在桌上,卡面上的数字比这顿饭贵十倍 |
| 两人关系很差 | 他把烟掐灭在她刚泡的茶杯里,她面无表情地把杯子推到一边 |
| 他很聪明 | 三秒钟。他看了三秒钟就把文件合上了。"第三页,第二行。" |

**核心方法**:
1. 用行为代替形容词
2. 用细节代替总结
3. 用对话代替旁白说明
4. 用反应代替情绪词

---

## 质量维度检查

### 1. 核心一致性(权重最高)
- 剧情是否与大纲/前文一致
- 人物行为是否符合人设
- 设定是否有前后矛盾

### 2. 表面改写(防AI指纹)
- 是否包含AI高频用词(见上表)
- 章尾是否有总结/升华
- 是否有大段纯心理描写
- 段落是否按戏剧单元/镜头自然断开,避免机械单句成段或为凑短碎成提纲(网文段落规则)

### 3. 格式一致性
- 对话格式统一:按项目/平台约定保持同一引号风格;知乎盐言短篇可用「」
- 标点节奏匹配语气:避免通篇句号化;保留有功能的问号和少量感叹号;用动作/短句表达迟疑或打断,不用省略号或破折号硬造停顿
- 场景切换有明显标记
- 时间线清晰可追踪

### 4. 可读性
- 是否有连续多个长句压住阅读节奏,且缺少动作、对话或短句换气
- 对话是否口语化
- 是否有未解释的生僻词/设定术语
- 节奏是否有快有慢(不能全是一种节奏)

### 5. 逻辑连贯性
- 角色动机是否合理
- 事件因果链是否清晰
- 时间线是否对得上
- 角色的知识范围是否合理(不能"开上帝视角")

---

## 快速自检口诀

```
一事一段,镜头自然断。
对话要像人说话。
心情不写心里话。
结尾不搞大升华。
打斗不写流水账。
日常要埋伏笔桩。
```

> 网文段落规则:按戏剧单元/镜头/一件事结束自然断段;短段快读,长段承载完整推理、氛围和情绪链,避免机械单句成段或通篇同长度。

---
> **番茄高分样本校准**:番茄正文更接近“手机端短段 + 自然虚词 + 场内动作/对话推进”,不是机械指标达标。番茄高分样本 305 章窗口显示:段落中位约 23.5 字,50-60 字行宽平均只占 5.1%;平均对话占比约 20.6%,对话≥50% 仅 3/305,开篇对话 59/305;`地/得` 305/305、`很` 275/305、`像/好像/仿佛/如同` 267/305、顿号 176/305、省略号 281/305。结论:这些只能按语境复核,不能做 0 容忍硬禁令。
>
> **反投机边界**:不要为了“反检测”强制每句换行、把 `……` 改成 `........`、把 `地/得` 全改成 `的`、禁用所有顿号/“很”/“像”、强行开篇对话或按三番四证重排章节。去 AI 味是润色,不是结构重写;除非用户明确要求重写,否则不改变章节顺序、伏笔分布、对话占比和人物信息释放节奏。

---

## 禁用词与句式速查

> 完整禁用词表和句式模板见 [banned-words.md](banned-words.md)

### 正确替代示例
- '他感到一丝紧张' -> '他的手在抖'
- '她很伤心' -> '她背过身,把袖口攥皱了'
- '"好的。"他说道' -> '"好的。"他把门卡塞回口袋'
- '他深吸一口气' -> '他把话咽回去'

---

## 10 种 AI 写作模式检测

### 模式 1:AI 高频词

| 禁用 | 替换为 |
|------|--------|
| 不禁 | 删掉 |
| 仿佛/宛如 | 删掉或用具体描写 |
| 映入眼帘 | 删掉 |
| 心中暗道 | 用动作展示思考 |
| 沉声道/淡淡地说 | 换成动作标签 |
| 脸色一变 | 用具体表情/动作 |
| 嘴角微扬 | 他笑了/他翘了下嘴 |
| 不由自主 | 删掉 |
| 只见/此时此刻 | 删掉 |
| 目光如炬 | 删掉或具体化 |

### 模式 2:弱化副词泛滥
阈值:每 1000 字超过 3 个 = AI 签名。重点监控:微微、淡淡、缓缓、轻轻。

### 模式 3:意义膨胀
- "意义深远" -> 写具体后果
- "前所未有" -> 给出对比参照
- "可谓" -> 删掉

### 模式 4:万能结论
- "未来可期" -> 用未解决的紧张感结尾
- "前途无量" -> 删
- "充满希望" -> 写具体的下一步动作

### 模式 5:论文体段落结构
小说中出现以下开头句 = AI 入侵:
- "不难看出""由此可见""事实上""综上所述"

### 模式 6:书面语连词泛滥
叙事散文中频繁出现:"于是乎""与此同时""从而""因而""诚然" -> 口语化替代或直接删除。

### 模式 7:三连排比癖
AI 喜欢把事情凑成三个以显"完整"。-> 砍到只剩最有力的一条。

### 模式 8:解释腔 / 上帝视角 / 安排感
最难察觉、却最"像 AI"的一类。叙述者跳出角色当下,去解释、剧透、总结、定性、拔高,读者能闻到"作者在场"和"剧情被安排好了"的味道。这正是"说教感/上帝感/解释腔/机械感/刻意感/安排感"的来源。

| 表现 | 例(删/改) |
|---|---|
| 解释因果 | 「之所以…是因为」「原来…」「这意味着」「正是因为」-> 删。因果只从角色动作、对话、反应里让读者自己拼 |
| 上帝视角剧透 | 「她不知道的是」「殊不知」「多年以后」「冥冥之中」「仿佛预示着」-> 删。只写角色此刻知道的,悬念让读者自己悬 |
| 替读者下结论/定性 | 「演得真好」「这出戏她看过一遍」「他就是这样薄情的人」-> 删。把证据(神态、动作、台词)摆出来,定性留给读者 |
| 替角色总结心理 | 「她明白,这一切都是命」-> 换成一句带偏见的闪念或一个身体反应 |
| 总结/动机/评价链把意义说满 | 「他终于明白」「这是最好的选择」「所有人都会记住这一刻」-> 删掉定性,改成角色当下要处理的具体缺口、未完成动作或局部反馈;不是保留评价再硬塞物件/动作 |
| 安排感/硬铺垫 | 为后文强行交代背景、整段回忆倒叙 -> 背景按角色此刻真实所需,用闪念、半句话、物件零碎带出,不集中交代 |
| 升华式收尾 | 结尾对仗拔高、金句点题 -> 用一个动作或一句留白收住,把"意思"压进画面里 |
| 抽象命运/开端收束 | 「命运终于露出獠牙」「早已布好的棋局」「这一刻终于明白」「属于他的反击才刚刚开始」-> 改成角色当下可见的文件、动作、对话或物理后果;`check-ai-patterns.js` 报 `abstract-summary-tic` 时优先处理 |
| 套词密度过高 | 仿佛/一丝/一抹/深吸一口气/平静无波/指节泛白等成串复现(`cliche-density-tic`)-> 不是同义词轮换,整段回到角色当下证据:文件、动作、对话、物理后果 |
| 比喻密度过高 | 像/好像/仿佛/如同等比喻标记成片复现(`metaphor-density-tic`)-> 保留最能传递信息或情绪的一两个,其余改回具体动作、物件、声音、后果;不要换成新比喻 |
| 系统公告公文腔过密 | 方括号规则/面板/公告行里硬规则词成片(`system-notice-formality-tic`)-> 保留为角色看见的屏幕/公告/规则载体;只在载体内部白话化部分硬词,或补角色当场看懂的具体后果,不改成叙述者解释 |

**更隐蔽的一层(最难自查,没有标志词)**——同样是安排感/上帝感:
- 评判性副词/补语:「关切得恰到好处」「笑得恰如其分」「不多不少」-> 作者在替读者盖章"这是装的"。只写动作("她掩了帕子,眼睛没动"),装不装让读者自己判。
- 剧透式点破潜台词:「那点笑她看得分明」「谁都看得出他在撒谎」-> 把藏着的挑明了。留着别点破。
- 定性比喻/盖棺句:「像在宣判一件早已定好的事」「像看一件死物」-> 比喻在替角色下定论。非角色此刻强烈主观感受就删;要留也只能是她带偏见的瞬间感觉,不是客观断言。

自检:每句问一遍——这是"角色在经历",还是"作者在讲解/安排"?凡作者跳出来讲,删,或改成角色视角内的呈现。根治办法是锁定深度限知视角(见 writing-craft.md「视角姿态:深度限知」),镜头钉死在角色身体里,作者就没位置跳出来了。

改法优先级:先删或原位替换污染句,不在段尾另补“人味”尾巴。需要补信息时,把原来的总结/动机/评价句改成角色当下能碰到的问题、手续、回信、付款、门外动静等具体压力;已有手机/屏幕/公告/门牌/表单等信息,优先作为角色看见的场内载体保留,不要转写成叙述者解释。具体载体跟剧情走,不套固定清单。

**任务卡点不是固定公式,也不是通用补流程按钮**:它只是把已有解释落回角色当下要处理的缺口。先问原文有没有“要办的事”和“卡住的点”;有,才可以压成任务卡点;没有,就只删解释或改动作/对话,不新造事件链。改完再做“删掉试试”:删掉后不影响信息、情绪、关系、代价或伏笔,就压缩或删除。

**但删解释腔 ≠ 把读者读懵**:新名词/新设定/新道具首次出现时,仍要让读者抓到一个锚——靠角色的动作反应、对话里半句自然提及、或场景里的物理后果,一笔带出它此刻的作用或分量;既不整段讲来历原理,也别只甩个零信息生词让读者干懵。人物记忆、情绪缓冲、因果承接也一样:如果一句看似解释/评价,实际承担小连贯(让读者知道角色为什么脸热、为什么停顿、为什么这一声压不住),不要机械删成摘录清单;把它压成角色当下的白话、动作、物件或半句念头。例:「蓝晶」首次出现不写"这是储存记忆的装置",但可写她把蓝晶按上太阳穴、别人的记忆碎片炸开在眼前——功能被读者看见,全貌留作悬念。区分:锚是"角色此刻撞上的可感知后果/记忆或情绪承接"(留或压),解释是"作者跳出来讲设定来历/原理/替读者下结论"(删)。

### 模式 9:过度压缩(电报体)

去AI味删过头的反向指纹。每句都压到最短、结构虚词扫光、每个动作都补一个「了下/了一下」式轻反应。单句看着干净,连读像提纲,读者的体感是"不流畅、喘不上气"。删减的目标是删废话(解释、注水、凑数),不是删中文的自然冗余。

| 表现 | 修法 |
|---|---|
| 非峰值叙述句也全部压成最短句 | 重拍句(动作/情绪/悬念峰值)保持短促;铺垫、过渡、日常动作写成自然白话句,保留 了/的/就/的时候 等结构虚词 |
| 「扯了下/停了一下/拍了两下/松了半圈」式微动作高密度复现(check-ai-patterns.js 报 micro-action-tic) | 合并动作,换具体细节;不是每个动作都要接一个反应尾巴 |
| 强调副词(连/才/又/只/全/反而)被扫光 | 删前判语义:承担人设、对比、讽刺义的保留("才二十三天"删掉"才",人设强调就反了) |
| 对话语气词归零 | 按角色保留自然低频的 呢/吧/啊;也不反向猛加——人味来自结构自然,不是聊天腔 |
| 叙述残留公文/文言腔(不得/须/未/已然/当前) | 换白话(不能/要/还没/现在)。系统公告、规则条文、面板播报可以保留冷硬功能;若 `system-notice-formality-tic` 报警,只在原载体内白话化一部分,不改成叙述者解释 |
| 长文本里短叙述段成片(`overcompressed-prose-tic`) | 不是把所有短段拉长。先人工通读:重拍短句、密集镜头如果上下文顺,就保留;只处理读起来像提纲的过渡句,把它们并回同一镜头,让读者顺着动作、空间、因果读过去 |
| 引号外叙述低连接密度且缺中长句(`low-connective-density-tic`) | 不是全局补“的/了/就”,也不处理台词/弹幕/系统播报的天然短促。先找叙述层读起来像提纲/电报体的断裂处,恢复必要连接、指代和中长承接句;有中长句链条的低功能词文本可保留 |

自检:删完连读一遍,读感像提纲或流水口令,就是删过了——把非峰值句恢复成自然白话,不是接着删。

本模式约束的是删减的度,不降低清理力度:禁用词、套路句式、告知式心理照删照改,模式 1-8 与 Gate A-G 全额执行;回填只回结构虚词和连接,不保留、不恢复任何模板措辞。

### 模式 10:二修伪自然(油腻倒装 / 监控动作清单 / 对话指标化)

一些“反检测提示词”会把文本推向另一种模板:为了提高突发性而乱倒装,为了真人感而机械加口误和脏话,为了手机阅读而强制每句换行,为了对话占比而把心理和叙述硬改成台词。这些不是自然网文,是二修痕迹。

| 表现 | 修法 |
|---|---|
| 油腻倒装 | 不写“手里拿着刀,他冲了上去”这类伴随动作前置。连续同主语时,优先用场内物件、声音、局部身体或环境反馈自然换句首;不要滥用死物拟人 |
| 监控摄像头式动作清单 | 同段连续“伸手拿起、取过、挑开、放下、转身……”像步骤表。合并琐碎动作,只保留有情绪、情节或空间功能的动作;必要时用角色犹豫、误判、旁人反应或环境反馈做缓冲 |
| 高压场景误脱水 | 冲突、追杀、打斗可删解释和逻辑胶水;日常、暧昧、铺垫不能全章脱水。删的是废话,不是“的/了/就/但是”等自然连接 |
| 吃字漏词 | 去 AI 后如果动词没有对象、动作指向不清、读者不知道谁对谁做了什么,要补回必要宾语、承载物或物理反馈;中文可省略,但不能省到像提纲 |
| 对话指标化 | 不为凑 50%-60% 对话占比硬扩台词。台词只在角色真会说、此刻必须说时增加;长对白可拆动作,解释性对白优先压成冲突、回避或半句信息 |
| 硬格式投机 | 不强制每句换行、50-60 字一行、不把省略号改成英文点、不把 `地/得` 全改错。按平台和项目既有格式走 |

`check-ai-patterns.js` 的 `action-list-tic` 只提示监控动作清单,不是 blocking。功能性打斗/追逐/仪式步骤若动作链本身承担信息,可保留或标 `[需复核]`。番茄高分样本中该类命中为 0,因此适合作为“需通读”的风格提示,而不是硬性失败项。

#### 工具提示处理

`check-ai-patterns.js` 是本地写作 lint;blocking 只限确定性句式/标点问题,advisory 不作完成门槛。用户贴其他工具报告时,只把能落到正文的句式、段落、词汇问题转成具体修改点,不写“0% AI / 100% 真人”或“固定公式”,也不围绕分数反复微调。

工具提示不高于读感规则。参考文本里若出现“仿佛/非常/感到”等套词或告知式心理,仍按模式 1-8 清理;不要机械补词、故意错字或按题材套壳。

**去 AI 味补充判断**:
- 优先处理:作者解释总结、意义尾巴、把情节翻译成“他意识到 / 这意味着 / 真正重要的是 / 这次成长”。优先删掉,或落回场内动作、对话、物件状态、任务状态和角色当场要处理的后果。
- 场内载体优先:原文已有手机、屏幕、公告、门牌、表单、账单、物证、规则行时,保留为角色看见/读错/处理的文本或物件;不要把同一信息改写成叙述者解释规则。
- 白话但不注水:少用精致戏剧反应短语(头皮发紧、眼皮一跳、心口一沉、胃里翻涌)连续替代剧情推进;能写普通动作/普通感觉就写普通动作/普通感觉,并保留自然的“的/了/就/但是/已经/之后/没有”等连接。
- 题材文风优先:文风对标有帮助,但必须来自目标题材/本书文风指纹;不要把盘龙腔、旧网文腔、第一人称声口等当成跨题材万能修法。
- 不要当通用修法:单纯加标题、补物件、补动作尾巴、拉长/压短句子、增加排队/门禁/记录体,不能替代具体的情节、视角和语言问题处理。

#### 把提纲句写成连续段落

当文本已无 blocking / 明显 advisory,但读起来仍像提纲时,只处理断裂处:

1. 标出读起来像逻辑报告的段落:连续出现“他知道/他明白/这意味着/真正的问题/必须/需要”等判断链,却缺少当下动作、物件或对话反馈。
2. 把叙述者结论落地:用角色当下能触到、听到、被迫处理的后果替代“他意识到/这意味着”。不要套固定物件清单,也不要把某个场景外壳当通用规则。
3. 只在断裂处恢复自然连接和结构虚词;不设比例目标,不机械补连接。
4. 系统公告、规则条文、面板播报可以保留冷硬短句;`system-notice-formality-tic` 报警时,只在原载体内白话化一部分硬规则词,或让角色当场看到具体后果,不改成叙述者解释。

`overcompressed-prose-tic` / `low-connective-density-tic` 的具体修法:

1. 圈出连续短叙述段,逐段标注功能:爆点/反转/恐惧重拍、密集镜头可继续短;铺垫、空间、因果、动作承接应并回同一镜头。人工读着顺,就不因该 advisory 继续拉长。
2. 合并时优先补“动作顺序、空间方位、因果承接”,例如“抬头时/门外/已经/还/就/被”,而不是给每句硬塞“的/了/就”。
3. 合并后再删套词和告知心理:读顺不是恢复 AI 腔,不能把“仿佛/感到/非常/好像”成片加回来。

复核处理:如果清掉 `overcompressed-prose-tic` / `low-connective-density-tic` 后读感仍不稳,停止局部微调,转为段落级重写或人工读感对照。

示例:

```
过度压缩:
林遥抬头。
雨棚外的街灯灭了。
风也停了。
柜台上的纸杯晃了两下。

读顺后:
林遥抬头时,雨棚外的街灯正一盏盏熄下去。风忽然停了,柜台上的纸杯还在原地轻轻打转。
```


---

## 系统性去AI三遍法

### Pass 1:去泛化(Strip Generic)
- 抽象情绪总结句 -> 删或替换为具体动作
- 假深度句 -> 删
- 意义膨胀 -> 缩小到具体影响
- 空洞结论 -> 删
- 工整对比句式 -> 打散重写
- 装饰性形容词堆砌 -> 白描
- 过度使用"于是""然而""此刻" -> 删掉一半
- 所有角色说话一样"高级" -> 区分语气

**原则**:能删就删,不能删就用具体细节替换。这一遍去掉80%的AI味。

### Pass 2:去书面化(Cut Professional Diction)
- 分析性用词("机制""结构""逻辑""体系"出现在小说中)-> 换成日常表达
- 抽象名词滥用 -> 直接说事
- 体制内用语("进一步""深入""推进""落实")-> 删
- 专业术语堆砌 -> 只保留必要的,用白话解释

**例外**:保留专业感的场景(历史题材正式用语、文学向刻意密度、喜剧夸张修辞)。

### Pass 3:回自然感(Restore Natural Presence)
- 具体的感官细节(气味、温度、触感)
- 角色说话方式的区分(不同人不同语气)
- 句首变化:连续 3+ 句用同一主语或同一词性开头时换开法(动作、场景、对话引入)
- 节奏变化(长短句交错):按情绪 beat、动作推进和戏剧单元自然调节句段长短;忌连续多段同一长度,也忌为凑短而碎成提纲。长短不是随机,沉淀处可放慢,冲突/反转处可骤短,完整推理与情绪链优先保持连贯
- 社会位置感的对话(上级和下属说话方式不同)
- 场景特有的记忆点
- 项目特有的语言习惯(角色的口头禅)

**原则**:少即是多。每段加 1-2 个具体细节就够了。

### 升级策略

| AI味程度 | 策略 |
|----------|------|
| 轻度 | 只做 Pass 1 |
| 中度 | Pass 1 + Pass 2 |
| 重度 | 完整三遍 + 重点段落重写 |

### 自检清单
- 对话自然度检查:对话是否使用口语化表达,是否避免了书面语/正式腔调
- 删掉任何一句,会影响理解吗?不会 = 可能多余
- 不同角色能通过对话区分吗?
- 有没有一个细节是这个场景特有的?

---

## 去AI味补充技法

### Show vs Tell

| 告知类型 | AI写法 | 自然写法 |
|----------|--------|----------|
| 告诉期待感 | "他很期待" | 展示期待->情绪->满足的链条 |
| 告诉角色目的 | "她想离婚" | 用行动展示目的 |
| 告诉角色态度 | "她很冷静" | 用对话和反应体现 |
| 告诉剧情走向 | "接下来会发生大事" | 用铺垫->反转->延续展示 |

### 心理描写润物细无声

- 加括号标注内心活动 = 破坏代入感
- 大段内心独白解释动机 = AI签名
- 直接写"她感到""她意识到" = 告知情绪

**自然写法**:心理活动自然融入叙事,用行为暗示心理,用沉默/动作/反常行为表达内心。

### 代入感检查
- 主角行为读者能理解、共鸣、接受吗?
- 反派够强吗?(弱反派 = 读者觉得主角赢了没意义)
- 是否围绕人设写行为?(行为/语言/思维围绕人格展开)
- 读者已知信息是否被有效操控?(信息差制造情绪波动)

---

## 改写范例库

### 情绪外化范例

**紧张**
- '他感到一阵紧张,心跳不由自主地加快了'
- 他攥紧了手里的纸杯,水洒出来一些

**愤怒**
- '愤怒在他心中燃烧,他不由得握紧了拳头'
- 他把筷子往桌上一拍,碗里的汤溅了出来

**悲伤**
- '一丝悲伤涌上心头,她的眼中闪过泪光'
- 她低头搅着咖啡,搅了很久

**害怕**
- '恐惧瞬间笼罩了他,他感到一阵战栗'
- 他的背贴在墙上,不敢动

**失望**
- '她感到一丝失落,心仿佛被什么东西揪住了'
- "哦。"她把手机锁了屏

**惊讶**
- '他的瞳孔微微收缩,显然没有想到会听到这样的话'
- 他张了张嘴,什么都没说出来

### 场景描写范例

**AI风场景**
- '阳光透过窗帘的缝隙洒进来,在地板上投下斑驳的光影。空气中弥漫着淡淡的花香,仿佛整个世界都沉浸在一片宁静祥和的氛围中。'
- 下午三点,客厅里只有钟在走。

**AI风天气**
- '天空阴沉沉的,乌云密布,仿佛随时都会下起倾盆大雨。凛冽的寒风呼啸而过,带着一丝刺骨的寒意。'
- 要下雨了。风把晾在外面的衣服吹得乱晃。

**AI风打斗**
- '他的拳头犹如疾风骤雨般猛烈,每一击都蕴含着不容置疑的力量。对手的瞳孔微微收缩,显然没有预料到如此凌厉的攻势。'
- 他一拳怼过去,对方没躲开,嘴角破了。

### 结尾改写范例

**升华式结尾** -> '他站在窗前,望着远方的天际线,终于明白了生活的真谛:有时候,放手才是最好的选择。' -> 他把烟掐了,回屋睡觉。

**总结式结尾** -> '这一刻,一切都变了。她知道,从今以后,她的人生将翻开崭新的一页。' -> 她关上了那扇门。没回头。

**感慨式结尾** -> '岁月如流水般悄然流逝……' -> 直接删掉这种段落。

### 节奏调整范例

**排比句**
- '他看着她的眼睛,看着她的嘴唇,看着她微微颤动的睫毛,心中涌起一股难以名状的情感。'
- 他看着她。她没说话。

**长句拆短**
- '当他终于推开那扇沉重的木门时,映入眼帘的是一间昏暗的房间,空气中弥漫着陈旧的气息,墙角堆满了落满灰尘的箱子。'
- 门开了。屋里很暗,角落堆着几个箱子,上头全是灰。

**工整段落打碎**
- '她喜欢春天的花朵,喜欢夏天的阳光,喜欢秋天的落叶,喜欢冬天的白雪。每一个季节都有它独特的美。'
- 她喜欢春天。别的季节也还行。

---

## 冲突对话改写范例

### AI式温和对话
- '我觉得你这样做不太合适,能不能考虑一下我的感受?' -> "你眼里还有我吗?"

### AI式完美解释
- '其实我这样做是有原因的,因为当时的情况非常复杂……' -> "你能怎么着?"她把茶杯重重放下。

### 对话情绪五级递进范例

同一冲突场景,从弱到强:

1. **客观陈述**:"你把我的东西扔了。"
2. **陈述+建议**:"你把我的东西扔了,以后能不能先跟我说一声。"
3. **主观指责**:"你凭什么动我的东西。"
4. **指责+命令**:"你算什么东西,也配碰我的东西?滚出去。"
5. **指责+PUA**:"我伺候你吃伺候你穿,你连个东西都放不好。你这辈子也就是这样了,离了我你什么都不是。"

### 震惊分层改写范例

**AI式一步到位**:所有人都震惊了,不敢相信自己的耳朵。

**自然分层震惊**:
1. 对面的男人手抖了一下,茶杯里的水洒出来。
2. 旁边的人互相看了一眼。有人往后退了一步。角落里有人开始掏手机。
3. 刚才还趾高气扬的女人,脸上的笑僵住了。她张了张嘴,一个字没说出来。

### 代入感修复范例

**被动主角**:她很害怕,不知道该怎么办,只能等着事情过去。

**主动主角**:她锁了门,把手机调成静音,打开了录音。

---

## 质量检查清单

写完每章后,按此清单逐项扫描:

- [ ] **段落控制**:段落按动作/信息变化断开,读起来不卡
- [ ] **正文无破折号**:正文(含叙述和对话)无 `——`/`—`/`--`(用句号、逗号、短句或动作断句),不设置对话例外
- [ ] **AI高频词扫描**:无不禁/仿佛/映入眼帘/心中暗道/沉声道/嘴角微扬/不由自主/只见
- [ ] **弱化副词计数**:每1000字"微微/淡淡/缓缓/轻轻"不超过3个
- [ ] **无三连排比**:没有AI式的"三个一组"修辞
- [ ] **无论文体**:无"不难看出/由此可见/事实上/综上所述"
- [ ] **无书面语连词堆砌**:无"于是乎/与此同时/从而/因而/诚然"泛滥
- [ ] **章尾无总结升华**:用动作/对话/悬念收束,无感悟/哲理/预告
- [ ] **无大段心理描写**:心理活动不超过2段,无括号标注内心
- [ ] **情绪用动作展示**:无直接写"愤怒/伤心/紧张",全用身体反应
- [ ] **对话口语化**:无书面腔,不同角色语气可区分
- [ ] **标点不压平**:没有把质问、爆发、犹豫全部压成句号;也没有随机堆砌 `?`/`!`,或用 `……`/`——` 硬造停顿
- [ ] **Show Don't Tell**:用行为代替形容词,用细节代替总结
- [ ] **句长匹配场景**:打斗和冲突段更短,日常和描写段可稍长,但朗读不卡、信息不过载
- [ ] **少电报体风险**:非峰值叙述保留自然虚词,「了下/了一下」式微动作不复读;若 micro-action-tic 提示,先通读判断,确属机械复读再合并动作,功能性轻反应可留或标 `[需复核]`
- [ ] **少抽象总结腔**:命运/棋局/这一刻终于明白/才刚刚开始不成串复现;若 abstract-summary-tic 提示,确属拔高定性再改,结尾优先用动作、物件或未解决问题收束
- [ ] **少套词堆叠**:仿佛/一丝/一抹/深吸一口气/平静无波等高危套词不聚集;若 cliche-density-tic 提示,确属模板腔再具体化,不做同义词轮换
- [ ] **少比喻堆叠**:像/好像/仿佛/如同不成片复现;若 metaphor-density-tic 提示,只留最有功能的比喻,其余改回具体画面,不换一批新比喻
- [ ] **少解释链报告腔**:知道/明白/这意味着/必须/需要等判断链不聚集;若 reasoning-chain-tic 提示,确属报告腔再落到动作、物件、对话和现场反馈
- [ ] **少系统公告公文腔**:方括号规则/面板/公告行里的硬规则词不成片聚集;若 system-notice-formality-tic 提示,保留为场内载体,只在载体内部白话化部分硬词或补角色当场看懂的具体后果
- [ ] **少未解释的过度精炼短段/低连接密度**:长文本短叙述段不呈现提纲/电报体;若 overcompressed-prose-tic 或 low-connective-density-tic 提示,先通读判断。有意短镜头且上下文读着顺可记录为可留;确有断裂感时,只补断裂处、必要结构虚词和中长句群,禁止机械注水
- [ ] **少监控动作清单**:同段不要连续罗列“伸手/拿起/取过/放下/转身”式琐碎步骤;若 action-list-tic 提示,先判断动作链是否承担打斗、追逐、仪式或信息功能,确属步骤表再合并动作、补视角缓冲
- [ ] **不做硬指标投机**:不为反检测强制每句换行、50-60 字一行、对话 50%-60%、英文点省略号,或把 `地/得` 全改成 `的`
- [ ] **任务卡点服从原文边界**:抽象总结若改成角色办事被卡住,必须来自原文已有任务/证据/手续/物件缺口;不新增原文没有的事件链
- [ ] **去AI三遍法执行**:轻度只做Pass1,中度做Pass1+2,重度完整三遍
- [ ] **对话自然度测试**:无书面语痕迹 = 通过
references/banned-words.md
# AI味禁用词与句式表

<!-- 同名副本×6 字节同步,改动后跑 scripts/check-shared-files.sh -->

## 最毒禁用句式(出现即修,最高优先级)

写网文最毒的 AI 句式,作者一旦养成就会反复出现。Gate A 第一遍扫描必须命中:

| 毒级 | 句式 | 错误例 | 修法 |
|------|------|--------|------|
| ★★★★★ | "不是A,(而)是B" / "不是A,不是B,(而)是C"("而"可省略,省掉也算命中)| "他不是冷漠,而是绝望" | 直接写 B 或用更自然的表达 |
| ★★★★ | ",带着……" 万能状语 | "他笑了一下,带着一丝不易察觉的嘲讽" | 拆短句或换动作 |
| ★★★★ | 无情绪声线:"声音不大,却带着……" / "语气毫无波澜" / "平静无波" / "声音平直/平平/听不出情绪" | "她声音不大,却带着不容置疑的力量" | 直接写台词内容、声音特征或动作 |
| ★★★★ | "他/她知道……" | "他知道这一切都来不及了" | 用行为展示认知 |
| ★★★ | "仿佛/犹如/宛若……一般" | "仿佛能穿透一切一般" | 删掉或白描 |
| ★★★ | "眼中闪过一丝……" / "嘴角勾起一抹……" | "眼中闪过一丝悲伤" | 他垂下眼 / 他嘴角一扯 |
| ★★★ | "心中涌起一股……" / "心头一震" | "心中涌起一股暖流" | 用身体反应 |
| ★★★ | 抽象命运/开端收束:"命运……棋局/獠牙" / "这一刻终于明白" / "反击才刚刚开始" | "命运终于露出獠牙;属于他的反击才刚刚开始" | 回到角色当下可见的文件、动作、对话或物理后果 |
| ★★ | 章末预告 "他不知道的是……" | "他不知道的是,更大的风暴即将来临" | 用具体钩子物件/事件收束,避免空泛预告 |

> 凡命中 ★★★★★ 一处即视为重度AI味的强证据;★★★★ 命中 ≥2 处即触发中度复扫。

**标点**:正文(含叙述和对话)禁用破折号 `——`/`—`、双连字符 `--` 和省略号停顿,改用句号、逗号、短句或动作断句;不设置对话破折号例外。盐言「」引号不在此列。

---

## 一级禁用词(出现即替换)

### 情态类
仿佛、犹如、宛若、如同、一丝、一抹、些许、几分、隐约、毫无征兆、几不可闻、微不可察

### 动作类
深吸一口气、缓缓、不禁、微微、轻轻、淡淡

### 表情类
眼中闪过、嘴角勾起、眉头微皱、眉眼低垂、瞳孔微缩、瞳孔收缩、瞳孔一缩、指节泛白、眼神锐利、目光锐利

### 心理类
心中一动、心头一震、心下了然、心中暗道、心底泛起、不由得、心中一凛

### 判断类
不容置疑、不容置喙、不易察觉、显而易见、毫无疑问、不可否认、前所未有

### 形容类
坚定、闪烁着光芒、狡黠、深邃、凛冽、冰冷

### 过渡类
不由自主、情不自禁、自然而然、话锋一转

## 二级禁用词(高频出现时替换)

### 语境敏感词(仅高频或偷懒时处理)
突然、陡然、骤然、猛然、好像、似乎、瞬间、猛地、死死地(角色口语、真实突发、时间压缩、视角不确定时可保留;用同义变体轮换规避重复不算豁免,按同一个词计密度)

### 书面腔 → 口语化

| 书面腔 | 口语化替换 |
|--------|-----------|
| 瓦解 | 消失 / 散了 / 没了 |
| 无名火 | 烦躁 |
| 往我心上捅刀子 | 心烦意乱 |

### 总结句式
- "他/她终于明白..."
- "他/她这才意识到..."
- "这一刻,他/她终于明白/意识到..."
- "从这一刻开始..."
- "属于X的反击/复仇/故事,才刚刚开始"
- "命运/宿命 + 齿轮/棋局/獠牙/改写/安排"
- "此刻,他/她..."
- "一切...都..."
- "原来..."

### 排比句式
- 连续3句以上相同结构的排比
- "有的...有的...有的..."
- "一边...一边...一边..."

### 升华句式
- "这一刻..."
- "他知道..."
- "她明白..."
- "这就是..."

## 禁用句式模板

| 句式 | 示例 | 问题 |
|------|------|------|
| "不是A,而是B" | "他不是冷漠,而是绝望" | 最毒;直接写 B |
| "...,带着..." | "他说,带着一丝无奈" | 万能状语 |
| "声音不大,却带着……" | "她声音不大,却带着不容置疑的力量" | AI 最爱声音描写 |
| "仿佛能...一般" | "仿佛能穿透一切一般" | 文言腔 |
| 对话标签密度过高/公式化标签 | "好的,他说道" | 普通"说"可保留;高频或公式化时处理 |
| "他/她感到..." | "她感到一丝失落" | 告诉而非展示 |
| "他/她意识到..." | "他意识到事情不对" | 直接告知 |
| "眼中闪过一丝XX" | "眼中闪过一丝悲伤" | 模板化 |
| "嘴角勾起一抹XX" | "嘴角勾起一抹冷笑" | 模板化 |
| "心中涌起一股XX" | "心中涌起一股暖流" | 模板化 |
| "取而代之的是" | "笑容消失,取而代之的是冰冷" | AI 过渡模板;直接写新状态 |
| "淬了/淬着X" | "眼里淬了毒" | AI 通感套路;写动作或台词 |
| "显得(有些)X" | "他显得有些兴奋" | 告诉而非展示 |
| "心底/心里某个地方+软" | "心里某个地方软得一塌糊涂" | 言情套句;写动作 |
| "(浑身)散发着一股X气息/气场" | "浑身散发着一股生人勿近的气息" | 万能气场描写;写旁人的反应 |
| "命运/宿命 + 齿轮/棋局/獠牙/改写/安排" | "命运终于露出獠牙" / "早已布好的棋局" | 抽象作者总结;改成角色当下撞见的文件、动作、对话、物理后果 |
| "这一刻终于明白/从这一刻开始/才刚刚开始" | "这一刻,他终于明白" / "反击才刚刚开始" | AI 收束腔;删总结,用动作或未解决问题收尾 |

## 比喻分类(默认复核,不默认全删)

带"像/如/仿佛/犹如/宛若"的比喻不是一律 AI。真正高风险的是:成片堆叠、套用万能文学比喻、用精致比喻替代剧情推进,或在段尾替读者总结意义。本表用于识别需要复核的比喻类型:

| 比喻类别 | 例 | 处理 |
|---------|----|------|
| 生活/角色化 | "像一头被抛弃的野狗" | 若贴角色视角、能传递信息或情绪,可保留 |
| 物品/现象类 | "像一把刀" "脸色惨白得像这漫天的雪" | 普通功能性比喻可留;模板化或重复时改白描 |
| 状态类(陈词滥调) | "梨花带雨" "如沐春风" | 优先删或改成具体动作/表情 |
| 抽象类 | "像命运的齿轮" "像上辈子的尘埃" | 高风险,优先落回动作、物件、声音、后果 |
| 假设类 | "力道大得像是要把骨头捏碎" | 若是角色身体感知可留;夸张堆叠时改事实后果 |

处理原则:先看功能,再看密度。保留最能传递信息或情绪的一两个,其余改为直接描述、动词、名词、作用、结果或事实;不要把删掉的比喻替换成另一批新比喻。例 "脸色惨白得像这漫天的雪" 若只是套话 → "脸色惨白";若雪景正在压迫角色,可保留或改成角色当下看到的具体画面。

> `metaphor-density-tic` 是 advisory:提示通读复核,不是 blocking;生活化、角色化、单个有功能的比喻可以保留。

## 替换策略速查

| 原文类型 | 替换方法 | 示例 |
|----------|----------|------|
| 抽象情绪词 | 具体动作 | "紧张" → "手在抖" |
| "感到XX" | 外化表现 | "感到愤怒" → "攥紧拳头" |
| 形容词堆砌 | 白描手法 | "美丽动人的笑容" → "她笑了" |
| 书面表达 | 口语化 | "不容置疑" → "就是" |
| 解释性描写 | 留白 | "他因为害怕而..." → "他退后一步" |
| 连续排比 | 保留最强一条 | 3 句排比留 1 句 |
| 总结升华句 | 直接删除 | "这一刻,她终于明白了..." → 删 |
| "不是A,而是B" | 直接写 B 或更自然的表达 | "他不是冷漠,而是绝望" → 直接写 B |
| 多余修饰(形容词/定语/量词/指示代词) | 删 | "白色的药片" → "药片";"手里那截链子" → "链子";"飞驰的汽车" → "车" |

**替换不复用**:右列是方向示例,不是标准答案。同一禁用词在一章内多次命中时,各处给不同的具体化写法;同一个替换写法反复出现(每次都「垂下眼」、每个动作都补「了一下」),替换产物本身就成为新的模板指纹。

**套词密度优先处理**:`check-ai-patterns.js` 报 `cliche-density-tic` 时,说明禁用词不是零星误用,而是聚成了模板腔。处理顺序不是同义词替换,而是先删抽象总结,再把情绪/判断落到角色当下可见的动作、物件、对话和具体后果。
references/character-relations.md
# 角色关系与感情线操作手册

## 决策路由

| 你在设计什么 | 使用本章方法 |
|-------------|-------------|
| 角色间关系类型 | 人物关系类型表 |
| 感情线核心人设 | 感情流人设核心法 |
| 爱情线底层逻辑 | 男频/女频爱情线差异 |
| 穿书/穿游戏角色选择 | 穿书角色选择法则 |
| 多角关系/修罗场 | 修罗场收场策略 |
| 好感度推进节奏 | 好感度体系 + 男频恋爱文攻略 |
| 配角态度变化 | 配角攻略缓冲区 |
| 角色共情写作 | 角色行为自洽检查 |
| 角色目标与关系线 | 角色目标独立性 + 关系线设计 |

---

## 人物关系类型

查表确定角色间的关系类型,然后按原则执行。

| 关系类型 | 定义 | 功能 | 示例 |
|---------|------|------|------|
| 冲突型 | 双方利益/理念对立 | 制造张力,推动情节 | 宿敌、竞争对手 |
| 联盟型 | 双方有共同目标 | 提供助力,制造羁绊 | 战友、师徒 |
| 亲密型 | 情感纽带连接 | 制造软肋,提供情感支点 | 恋人、家人、兄弟 |
| 权威型 | 上下级/支配关系 | 制造压力,限制主角行动 | 师父、老板、监管者 |

**执行规则**:
- 每个重要关系至少安排一次考验(背叛/牺牲/误解)
- 关系必须有变化弧线(敌人变盟友、盟友变对手)
- 禁止所有关系都是"你好我好"的铁板一块
- 关系的功能必须服务于情节,不能只为甜/虐而存在

---

## 感情流人设核心法

感情流中剧情为人设服务——每次剧情都要丰满人物或推动感情发展,否则就是无效剧情。

### 构建步骤

1. **确定人设核心**:写下最初出现在脑海里的角色特征(如"想登上皇位的皇子""病弱皇子")
2. **围绕核心发散**:回答——为什么有这个核心?过去经历?环境影响?性格成因?
3. **延伸剧情线**:核心自带的剧情必须写(有目标→目标线;有伤痛→治愈线)

### 执行规则

- 俗套剧情配上丰满人设也能脱离套路感
- 两个主角都必须有闪光点和独立变化路线,不能一个人出彩另一个人黯淡
- 高光不等于装逼——所有让读者对角色印象深刻的时刻都是高光,包括痛彻心扉的时刻
- 生命不止恋爱——角色心里希望恋爱,但生命里不能只有恋爱,只有恋爱的角色立不住、很单薄

---

## 男频与女频爱情线底层逻辑差异

**先确定目标读者群,再选择对应逻辑。两者不可混淆,否则读者会觉得"不对味"。**

### 男频爱情线逻辑

| 维度 | 说明 |
|------|------|
| 核心 | "外在因素展示"——主角和对象在一起体现两人的外在因素多优秀 |
| 三种核心逻辑 | "她要是我的该多好" → "这么优秀的人是我的了" → "这么优秀的人都喜欢我,我更优秀" |
| 围观者想法 | "能得到这么优秀的女人,我好羡慕"(非"恋爱好甜") |
| 女主 | 最好有多个女性角色喜欢主角,越多说明主角越优秀 |
| 对象本质 | "奖杯"——一切动机的根本目的是胜利 |

写男频感情线时,围绕"展示优秀"设计事件。

### 女频爱情线逻辑

| 维度 | 说明 |
|------|------|
| 核心 | "连接"——两人之间唯一、坚不可摧、最优先的情感连接 |
| 连接要求 | "我爱的是你这个人,外貌、财富、才华都不能替代这份连接" |
| 纯洁性 | 连接形成后应逐渐舍去外在因素影响 |
| 双方 | 最好初恋+双洁——保障连接的纯洁性和唯一性 |
| 围观者想法 | "他们的恋爱好甜,我好羡慕" |

写女频感情线时,围绕"深化连接"设计事件。

---

## 穿书/穿游戏角色选择法则

### 必须满足的条件

- 叙事主角必须对原世界有相当熟悉度(最核心的期待感来源)
- 确定穿越进熟悉的世界,一两句话交代清楚,不能超过一章还不知道身处何方
- 必须选择穿越后有强戏剧性或强矛盾的角色(如穿越成恶毒女配)

### 信息差设计

- 在原世界基础上适当设计信息差,为叙事主角提供探索空间
- 可以用原世界知识卡bug获取超额收益

---

## 修罗场收场策略

修罗场本质是一种矛盾,控制对抗烈度,不能让爱情线对象之间出现不可调和的矛盾。

| 收场方法 | 操作 |
|---------|------|
| 关联回事业线 | 让爱情线对象们把争风吃醋转化为"比谁对主角事业线贡献更大" |
| 插入事业线突发事件 | 对抗进入白热化时,用突发事件让所有人从内斗切换成一致对外 |
| 一笔带过 | 最多两人互相看不惯、说两句嘲讽话就过去,非常克制 |

---

## 傻白甜角色塑造避坑

### 六个必避之坑

1. 女主犯错不能是故意的,不能明知不能做偏要做
2. 女主犯错不能违反道德——傻白甜最重要的是天真小孩子般的高道德感
3. 不能站在道德高地指责他人(道德婊行为),自己却做不好
4. 不能因为道德婊行为和队友发生矛盾后还让队友认错赞美她
5. 正确做法:有更高道德标准 → 所作所为符合 → 为此显得与众不同和有点傻气
6. 可以写成长线:坐到被她指责的人的位置上后,发现原来那个做法已是最好的选择

### 反向用法

塑造傻白甜反派时,把以上六点全加在她身上,让读者一见就厌恶。

---

## 人设改变的双向翻转法

爱情线中关系发生变化时的人设调整方法。

### 核心思路

- 最好的改变是两个人都改变,且改变后恰好与之前两人之间的对应关系相反
- 改变要基于原人设做局部调整,和原人设保持密切联系,避免随意改造

### 执行规则

- 人设变化发生在爱隔山海阶段之后(尤其是面对最大阻碍之后),不要再设计新的矛盾,只要发糖
- 真正的阻碍已在前面解决,此时读者最迫切的渴望是多吃糖
- 发糖要用实在的CP行为和悉心照顾表达爱意,不是"我爱你"式的工业糖精

---

## 角色行为自洽检查

### 第一步:排除外部强推

检查角色行为是否只是为了让剧情往某方向发展。如果角色"恰好"做了推进剧情的事但没有自身动机,标记为"人设偏移",必须为该行为补充角色层面的合理理由。

### 第二步:情绪目标确认

写每段前明确三个要素:
1. 本段目标情绪:这段剧情要让读者产生什么情绪?
2. 角色性格特征是否支持该情绪:角色的设定属性是否自然导向此情绪方向?
3. 角色行为是否符合使命定位:角色在本段的行为是否与其在故事中的功能定位一致?

### 第三步:人设行为推导

从角色已设定属性(经历/性格/目标/当前状态)推导其在场景中的行为:
- 列出该角色在此场景下的2-3种可能反应
- 评估每种反应与角色已有行为模式的一致性
- 选择最符合人设且最有戏剧张力的那一种

### 第四步:情绪一致性校验

检查角色表达的情绪核心与场景目标情绪是否一致。不一致则调整角色行为或场景目标情绪,确保输出内容在情绪维度上自洽。

**两种执行路径**:
- 路径A:先定目标情绪 → 按人设推导角色行为 → 校验一致性
- 路径B:先按人设推导角色行为 → 反推场景目标情绪 → 校验一致性

---

## 配角攻略缓冲区

配角对主角态度的变化过程 = 主角攻略配角的过程,伴随巨大的期待感和爽点。

### 缓冲区类型

线上线下、背后议论、异地相处、地位差距、亲密度差距、信任程度、信息差等。

### 操作步骤

1. 始终保持缓冲区存在
2. 在卷纲中挑出事件拐点(5~7个)
3. 在每个拐点处标注配角状态和对主角的态度变化
4. 每次攻略到关键点位时,配角的态度变化必须写清楚
5. 态度变化本身就产生情绪波动和期待感

### 执行规则

- 配角不能像NPC一样站着等主角触发
- 配角要有自己的行动,由配角观点引出事件
- 正面角色也一样:和主角立场相同的人也应有自己的行动和动机

---

## 利用角色身份认知差制造冲突

同一个角色在不同人眼中的"声望"是动态变化的,不是恒定值。

| 视角 | 看重什么 | 对男主的评价 |
|------|----------|-------------|
| 世俗视角 | 家境对等,抗风险能力 | 综合条件匹配度 |
| 男主自身 | 赚钱养家+感情 | 相对门当户对 |
| 女主视角 | 感情+专一+爱 | 只要在乎的就门当户对 |
| 富二代 | 外貌家世匹配度 | 我才更门当户对 |
| 路人 | 综合条件对比 | 女主该嫁富二代 |

**操作要点**:
- 不同人对同一个角色的评价差异 = 天然的矛盾冲突来源
- 恋爱文的核心爽点之一:不同维度的评价差

---

## 亦敌亦友关系

最有魅力的人物关系类型。

### 执行规则

- 前提:两个角色本身都要有魅力,否则只是强行五五开的狗皮膏药
- 核心:高度认可 + 绝对冲突 → 惺惺相惜 → 缺一魅力全无
- 真正的宿敌 = 双方相互认可,外人盖章不算

---

## 竞争者定位与亲情线用法

### 竞争者(鲶鱼效应)

- 定位:给男主/女主增加紧张感上压力 → 推动攻略进度
- 剧情设置重点在"高潮节点前" → 属于铺垫环节
- 对高潮后续写剧情作用不大 → 不是续命手段

### 亲情线的四个方向

| 方向 | 作用 | 使用时机 |
|------|------|---------|
| 男方助力 | 提供金钱地位权力 | 高潮前发挥 |
| 男方阻力 | 设置障碍让主角得到认可 | 节点前后都能用 |
| 女方助力 | 温柔乡感情补给站 | 高潮前发挥 |
| 女方阻力 | 父母不同意→为了认可去努力 | 节点前后都能用 |

助力主要在高潮前,阻力节点前后都能用,阻力更好发挥。

---

## 男频恋爱文写法攻略

### 读者为什么看恋爱文

| 价值 | 来源 |
|------|------|
| 情绪价值 | 填补"被需要、被在乎"的情感空缺 |
| 自尊价值 | 女主条件好→带出去有面子→配角羡慕嫉妒 |
| 高身份女主原因 | 更强的装逼打脸工具人+更大的自尊满足 |

### 核心技法:把恋爱当升级文写

- 暧昧拉扯的过程才是最有吸引力的
- 好感度进度条:每一步推进 = 升级文中的一个"等级"
- 每一个事件是一次"升级"→ 通过冲突、化解、理解和共鸣让好感攀升

### 好感度升级路径

路人 → 好人(扶老奶奶过马路) → 正直勇敢的好人(挺身而出) → 不错的朋友(理解原生家庭困境) → 喜欢但不知(特殊契机看到彼此不为人知的一面)

### 关键原则:主角不主动追求

- 当前男频读者普遍反感"舔狗"人设
- 推动好感靠男主自身优秀品质吸引,不是主动追求
- 男主帮女主是举手之劳不经意为之 → 女主因此记住他

### 感情升级的不对称性

- 两条进度线:表面社会关系(路人→朋友→情侣)+ 实际情感好感度
- 两条线不应齐头并进 → 一条快一条慢 → 带来丰富的矛盾冲突
- 情感线快于社会线 → 辉夜大小姐模式(双方好感拉满但嘴硬不表白)
- 社会线快于情感线 → 赘婿/择天记模式(表面夫妻实际好感为零)

### 写女主对主角好

| 女主类型 | 付出方式 |
|---------|---------|
| 自卑社恐 | 偷偷帮忙塞东西但害怕被发现 |
| 傲娇型 | 用心做便当但嘴硬"做多了顺便带的" |
| 直率泼辣 | 大大方方送礼物或直球表白 |

### 善用对比

| 对比类型 | 操作 |
|---------|------|
| 时间线对比 | 女主刚认识主角时 vs 熟悉后的态度变化 |
| 双标 | 不允许别人摸头但主角可以 → 凸显特殊地位 |
| 信息差 | 男女主双重身份(网上vs现实)→ 期待揭穿时反应 |

---

## 好感度体系(通用框架)

### 四阶段

萍水相逢 → 爱情喜剧 → 爱隔山海 → 大结局

### 好感度 × 关系阶段对照表

好感度(负/零/半/满) x 关系阶段(熟悉/试探/暧昧/确认)

阶段匹配原则:**按低的一方计算**。好感度到了但关系阶段没到 = 行为突兀。

### CP行为三类门槛

| 行为类型 | 门槛 | 说明 |
|---------|------|------|
| 需容忍行为 | 半好感+ | 主动亲密、妥协,必须给补偿否则变舔狗 |
| 特殊对待行为 | 半好感+ | 主权、信赖、牺牲、特殊待遇、安抚 |
| 关联行为 | 双方半好感+ | 默契、分享、陪伴 |

好感度不足时写需容忍行为 = 油腻/性骚扰感。

### 5套感情线结构模板

| 模板 | 核心 |
|------|------|
| 好感度变化 | 可能升/降/已降 → 各自处理路径 → 余韵 |
| 受益 | 享受伴侣带来的好处 = 爱情线装逼核心 |
| 争风吃醋 | 多角色为主角争风,不是主角吃别人醋 |
| 发展受阻 | 阻碍→试图解决→解决→余韵 |
| 狗粮 | CP日常互动 |

---

## 角色目标的独立性与关系线设计

### 主角目标独立性原则

- 主角的目标必须属于自己的 → 不能是"帮别人实现目标" → 否则主角变成配角/工具人
- 正确做法:把别人的目标转化为主角自己的(平叛=保护自己的利益/获得认可/获取资源)
- 自检方法:随机看一章 → 主角在主动追求什么 → 如果没有 → 主角沦为别人的棋子

### 感情线的层次设计

- 感情推进不是线性的,应该有升级节点,每个节点对应一个剧情高潮
- 社会关系线 vs 实质情感线的错位制造张力
- 最佳节奏:感情先慢后快 → 前期铺垫积累好感 → 后期集中爆发 → 匹配盛大仪式

### 配角的功能性定位

| 类型 | 功能 | 使用时机 |
|------|------|---------|
| 竞争者(鲶鱼型) | 制造压力推动主角行动 | 高潮节点前 |
| 助力型亲友 | 提供资源/情感支持 | 高潮前发挥 |
| 阻力型亲友 | 不认可→设置条件→主角克服→获得认可 | 节点前后都能用 |

---

## 绿茶/负面角色

- 可以推动剧情但要谨慎使用
- 确认该角色是否有不可替代性
- 主角强势时任何角色都能变正反馈
- 主角弱势时负面角色会被读者恨

---

## 质量检查清单

每次完成角色关系/感情线设计后,逐项核查:

- [ ] **关系类型明确**:每个重要关系已归类为冲突/联盟/亲密/权威之一
- [ ] **关系有弧线**:每个重要关系至少经历一次考验或变化
- [ ] **人设有核心**:主角人设有明确的核心特征和发散依据
- [ ] **目标独立性**:主角的目标属于自己的,不是帮别人实现目标
- [ ] **好感度匹配**:CP行为与当前好感度阶段匹配(按低的一方计算)
- [ ] **读者群对味**:男频围绕"展示优秀"设计事件,女频围绕"深化连接"设计事件
- [ ] **配角有行动**:配角不是NPC式站桩等待触发,有自己的行动和动机
- [ ] **缓冲区存在**:配角攻略过程中始终保持缓冲区,拐点处标注态度变化
- [ ] **修罗场可控**:多角关系中对象之间无不可调和的矛盾
- [ ] **发糖时机正确**:爱隔山海之后不再设计新矛盾,只发实在的CP行为糖
- [ ] **角色不止恋爱**:角色生命中有恋爱之外的内容,不是单薄的情感工具人
references/dialogue-mastery.md
# 对话设计操作手册

> 写对话场景时加载。先看决策路由选对话模式,再用操作指令控制质量。

---

## 决策路由

| 你的对话场景是 | 用这种模式 | 操作 |
|-------------|---------|------|
| 主角碾压/打脸 | 压制模式 | 对方长篇大论 → 主角一字回应 |
| 主角亮底牌/反转 | 反转模式 | 对方嚣张 → 主角一句话事实 → 对方沉默 |
| 关系破裂/心死 | 心死模式 | 对话越回越短:从辩解 → 沉默 → "随意" |
| 日常互动/立人设 | 日常模式 | 让其他人物参与冲突,不要主角一个人独白 |
| 群众震惊/弹幕 | 弹幕模式 | 递进:普通人震惊 → 专业人士分析 → 特殊身份者反应 |
| 信息展示/世界观 | 信息嵌入 | 用角色语气包裹信息,不是机械陈述设定 |
| 情绪拉扯/虐心 | 情绪推动 | 上行下行交替,像拉锯拉升期待 |

### 对话模式选择补充

| 对话类型 | 常见问题 | 修正操作 |
|----------|---------|---------|
| 问答式(通篇一问一答像审讯) | 改为一方主动说,另一方给反应;反应可用动作/表情/心理 |
| 冲突式(太礼貌不够劲) | 递进五级:委婉拒绝→友好人道→命令否定→PUA式→直接侮辱 |
| 日常对话(容易变成没功能的水) | 功能是立人设;能让人参与的冲突别让主角独白 |
| 多人对话(混乱/没营养) | 写前规划每个角色功能:信息提供者/情绪放大器/冲突制造者 |

---

## 对话核心规则

每句对话必须承载以下至少一项,否则删除:

1. **推进剧情**:透露新信息、推动事件发展
2. **增加期待感**:暗示即将发生的事、制造悬念
3. **展示人设**:通过语言风格传递角色性格

且在情绪场景里,每句还要**回应上一句对方的情绪状态**(承接/偏转/升级/退缩)——对话是两个人的情绪在碰,不是轮流播报信息。只推进剧情、句间无情绪承接 = 机械对话。

### 绝对禁止

| 禁止 | 理由 |
|------|------|
| 配角无脑夸主角 | 假 |
| 互相解释读者已知信息 | 水字数 |
| 大段说明文式对话 | 闷 |
| 所有角色说话方式一样 | 模糊 |
| 对话说服人物 | 现实中没人被几句话说服,用突发状况代替 |
| 情绪写完方向变了 | 需回退到情绪拆分 |

---

## 权力博弈对话

### 规则

对话长度 = 权力地位。掌控者话短且冷静,被动者话多且情绪化。

### 压制模式

结构:对方长篇大论(3-5 行)→ 主角一字回应。

```
"你以为你是什么东西?我告诉你,这个家轮不到你说话!你嫁进来的那天起就该明白自己的位置。"
"滚。"
```

### 反转模式

结构:对方嚣张(2-3 行)→ 主角亮底牌(1 行事实)→ 对方沉默。

```
"你有什么资格管?这是我家的钱,我想怎么花怎么花。"
"你妈的存折,密码是我的生日。"
```

### 心死模式

结构:对话越回越短,从辩解到沉默到「随意」。

```
"你听我解释,那天不是你想的那样。"
"嗯。"
"真的,我可以证明。"
"随意。"
```

### 操作指令

- 掌控者/主角亮底牌时:对话 ≤ 10 字,不加动作描写
- 被压制方:对话 ≥ 20 字,可加动作描写(攥拳/咬唇/站起来)
- 两人对话时:短句方 = 权力上位,长句方 = 权力下位

---

## 潜台词与议程

### 潜台词规则

- 角色真实动机绝对不能浅显地写在台词里
- 现实中人说话都给自己找借口,角色也一样
- 每句对白同时设计:角色的动机(可能角色自己都没意识到)和角色的借口

### 对话议程

- 每个角色进入对话时有自己的议程:想从这场对话中得到什么
- 两个角色的议程碰撞才是张力来源
- 双方议程一致(同立场)= 复述,失去意义

### 语气由三要素决定

关系 × 场合 × 目的 = 语气

| 场合 | 特点 | 适合内容 |
|------|------|----------|
| 私人(单对单) | 深入、感情流露透彻 | 内心剖白、密谋、表白 |
| 公众 | 需考虑体面,措辞收敛 | 需要冲击力时可打破(如当众翻脸) |
| 熟人(朋友/同门) | 深度介于两者之间 | 轻松互动和信息交换 |

私密的话在公众场合说才有冲击力。对话中不能完全表达的内容,通过动作、神态、环境补充。

---

## 情绪推动对话

### 强情绪对话

- 命令式+否定式最能激发读者情绪:"我说的还不够清楚吗?"
- 最强话术:打着为你好的幌子,句句不离关心,但句句都是嫌弃、指责、厌恶
- 直接否定比含蓄暗示更伤人

### 情绪连续性

角色情绪是连续的、循序渐进的。从生气到高兴:生气 → 不那么生气 → 不生气 → 高兴,每次转变需对应事件触发。不能跳步。

### 情绪四步法(缺一不可)

1. 遇到事件(失衡状态)
2. 情绪反应(外在表现:动作、表情、语言)
3. 内心思考(即使没思考也要写出"没有思考",表达鲁莽)
4. 采取行动(基于前三步结果回应)

### 对话不平淡三思路

- 对话本身带来/强化某个核心驱动力(期待、爽感、悬念)
- 信息交流因某原因受阻碍,阻碍可能导致驱动力到来
- 发展突然脱离读者预期(但必须合理、符合人设)
- 把长篇对话塞在期待点和爽点之间:读者为了看爽点愿意忍受中间对话

---

## 信息展示与世界观引出

- 大量信息通过对话展示会显得啰嗦,部分信息转化为情节、心理描写、旁白、环境、动作
- 用角色的语气和立场包裹信息,不是机械陈述设定
- 设定用到哪个稍微带出来就行,不需完完全全讲明白前因后果
- **角色不当"科普嘴"**:设定/原理/前因后果不能靠任何角色(尤其信息型/AI 配角)整段讲解——Gate G 同样适用于角色台词。拆成角色在压力下挤出的半句话 + 身体反应 + 留白,用到哪带哪点

### 信息拉扯示例(以"主角新书起飞了"为例)

| 角色 | 台词 | 功能 |
|------|------|------|
| 甲 | 听说成绩…… | 悬念拉期待 |
| 乙 | 肯定没人看! | 下行 + 拉期待 |
| 甲 | 听说成绩很不错 | 上行 + 拉期待 |
| 乙 | 首订一万三? | 展露核心信息 + 达成爽点 |

上行和下行交替,情绪像拉锯不断拉升期待。

---

## 人物语言差异化

每个角色要有自己的说话方式。对话时经常卡住不知道角色会说什么 → 人设模糊,去总结类似人设的说话方式。

| 差异化维度 | 操作 |
|------------|------|
| 口癖和惯用语 | 给每个主要角色一个标志性用词 |
| 说话节奏 | 长篇大论 vs 短句连击 |
| 信息偏好 | 技术型带专业术语,江湖人带切口 |
| 立场固定 | 某角色永远从某个角度发言(悲观派/乐观派/务实派) |
| 身份影响措辞 | 老者/少年/贵族/市井,身份不同则措辞、自称、敬谦词不同 |
| 性格影响语气 | 智谋型话里有话;鲁莽型想到什么说什么;冷静型措辞精确,偶尔情感外露反而有冲击 |
| 进度影响态度 | 初见/熟悉/对立/亲密,关系阶段不同则语气与信息量不同 |

---

## 弹幕/群众对话

三大核心作用:剧情推进(透露主角不知道的信息)、增加期待感(悬念、猜测)、情绪渲染(群众震惊/激动/愤怒传染读者)。锦上添花,不代替主线。

### 设计过的弹幕 vs 没设计的

- 没设计:只有"卧槽好厉害",单调、浪费
- 设计过:普通人震惊 → 专业人士分析 → 特殊身份者反应 → 情感升华

### 操作要点

- 不同人格化语气,不能每条都一个味
- 短小精悍,每条不超过一句话核心信息
- 善用递进:从最初震惊到逐渐认识全貌
- 可出现"反转"角色:看似路人一句话改变所有人认知
- 不用每章都写,关键爽点/燃点/泪点前后集中使用

---

## 节奏控制

### 大量对话保持节奏

- 不要删掉表现人物性格的语气助词来"精简"
- 对话段落间穿插动作描写、环境变化、心理活动调节节奏
- 紧张段落对话短促,舒缓段落可以长一些
- 关键信息放对话开头或结尾,中间用于拉扯情绪

### 动作和表情处理

- 刻意给每句对话配表情/动作会让行文机械
- 动作和表情在关键转折处使用效果最好,不需要每句都配
- 语气平淡场景(喝茶、散步)用微小动作和沉默体现氛围

### 对话的呼吸感

- 连续多轮对话后需要"换气",插入环境描写或角色心理
- 适当停顿(动作描写、换行、短句)比连续输出更有张力
- "你确定?"比长篇解释更有压迫感

---

## 篇幅控制

### 对话过多时

- 读者已知信息的对话用叙事一句话概括:"爱丽丝向安娜讲述了来城里的原因,安娜听后直皱眉头"
- 能用突发状况替代的对话段落直接替换
- 语气词删掉后干巴巴 = 对话本身缺乏信息量,需重写

### 对话过少时

- 能用其他人物对话讲出来的东西,不要让主角旁白平铺直叙
- 引入配角参与冲突和对话,但新人物必须安排主线戏份

---

## 以梗填充对话

### 梗式 vs 普通

- 普通:"兄弟别灰心,你一步步走到今天我是看着你过来的,这点挫折对于你来说算什么事儿?振作起来!"
- 梗式:"兄弟别灰心,我相信你总有人头落地,落地人头,头落地上……卧槽那句话怎么说的来着?兄弟你懂我意思是吧……"
- 梗式用"说不出来但意思到了"的状态制造趣味

### 操作

- 在对话中融入梗或骚话,有效提升整体趣味性
- 特别是主角或重要配角的突出对话,适合用梗强化记忆点
- 可用某个梗作为高潮点,整段剧情围绕达成这个梗来设计
- **场合例外(声线让位)**:高压/生死/悲痛/严肃 beat 里,搞笑担当与轻快配角的玩笑、口头梗、插科打诨一律收敛——声线让位于当前情绪基调,用短、冷、带情绪重量的反应替代;梗只在安全或喘息 beat 放。自检:这句玩笑放进当前基调会不会让读者出戏?会就删/改

---

## 质量检查

### 三大自查项(中一条以上需改进)

- [ ] 是否存在大量信息都必须用对话来展示
- [ ] 对话是否是问答式的一问一答
- [ ] 是否习惯依赖对话来推动剧情或人物变化

### 核心指令检查

- [ ] 权力博弈:掌控者对话 <= 10 字 / 被压制方 >= 20 字,是否有明确的压制/反转/心死模式
- [ ] 潜台词与议程:每个角色进入对话时有自己的议程,真实动机不在台词中
- [ ] 人物差异化:遮住角色名后能否区分是谁在说话(7维差异化)
- [ ] 弹幕递进:普通 → 专业 → 特殊身份,是否有层次感
- [ ] 对话推动剧情:每段对话结束时,剧情是否往前推了一步
- [ ] 篇幅控制:单次对话不超过全节 40%,信息密度是否足够

### 检验对话质量

- 对话自然度检查:逐句检查对话是否像自然口语交流,而非书面化的问答稿
- 对话结尾能否预示接下来的节奏变化
references/plot-core-methods.md
# 剧情核心方法 — 操作手册

> 小纲设计、高潮构建、卡文对策、循环设计、连续性追踪等剧情创作的核心方法。
> 遇到问题先查路由表,找到对应方法再操作。

---

## 决策路由表

| 你在做什么 | 用什么方法 | 跳转到 |
|-----------|-----------|--------|
| 建小纲/细纲 | 小纲四步法 | [小纲四步法](#小纲四步法) |
| 设计高潮 | 高潮构建公式 + 逆推法 | [高潮逆推法与AB粗纲](#高潮逆推法与ab粗纲) → [高潮构建公式](#高潮构建公式) |
| 卡文了 | 卡文对策 + 循环设计 | [卡文对策与剧情循环设计](#卡文对策与剧情循环设计) |
| 管理连续性 | 连续性追踪 + 节奏管理 | [连续性追踪与节奏管理](#连续性追踪与节奏管理) |
| 设计过渡衔接 | 剧情过渡 + 场景转换技巧 | [剧情过渡与衔接](#剧情过渡与衔接) → [场景转换技巧](#场景转换技巧) |
| 开书/设计噱头 | 噱头分类与开篇流程 | [噱头分类与开篇流程](#噱头分类与开篇流程) |
| 拉长剧情 | 设门槛 | [设门槛——拉长剧情的核心技巧](#设门槛拉长剧情的核心技巧) |
| 管理期待感 | 大剧情拉期待法 | [大剧情拉期待法](#大剧情拉期待法) |
| 写日常文 | 日常文大纲框架法 | [日常文大纲框架法](#日常文大纲框架法) |
| 判定是否自嗨 | 自嗨判定法 | [自嗨判定法](#自嗨判定法) |

---

## 小纲四步法

细纲与正文比例控制在 **1:2.5 ~ 1:3**。

按以下四步操作:

1. **分段判断** — 把大纲按剧情节点分段
2. **标注目的和效果** — 每段标注,不展开情节
3. **标注详写/略写** — 明确哪些段展开、哪些段带过
4. **快速定位** — 让后续写作能快速定位本段要交付的目的和效果

记住:细纲只关注目的和效果,不展开情节。

---

## 高潮逆推法与AB粗纲

### 核心思路

从高潮反推前面需要铺垫的人物和情节,再用AB交替法填充。

### AB粗纲法

| 标记 | 含义 | 操作 |
|------|------|------|
| A | 压情绪/铺垫/伏笔 | 铺设困难、对手强势、悬念埋线 |
| B | 抬情绪/擦边/小收获 | 小反转、小进步、读者爽一下 |

操作流程:确定高潮 → 反推所需铺垫 → ABABAB排列 → 写作时只关注当前AB段。

适用场景:节奏快、有明确高潮节点的剧情。

---

## 高潮构建公式

### 五步公式

按顺序执行:**蓄能 → 假胜 → 崩解 → 交叉死磕 → 悬置收尾**

1. **蓄能**:牺牲+焦灼打底,让观众从"看客"变"参与者"
2. **假胜**:先给希望再击碎(情绪落差 = 反转冲击力)
3. **崩解**:所有伏笔一次引爆 + 推入单人绝境(帮手全失)
4. **交叉死磕**:对抗线+绝境线来回切换(每切一次紧张感+1)
5. **悬置收尾**:余劲不散,胜负不立刻揭晓

关键操作:假胜是常用高潮技法。适合强反转、强压迫或大高潮;低压力章节、纯奖励章、日常/关系回收章可不用。没有假胜时,需用别的方式提供情绪落差或明确兑现。

---

## 噱头分类与开篇流程

### 三种噱头类型

| 噱头类型 | 特点 | 写法要点 |
|----------|------|----------|
| 事件噱头 | 集中在开篇,约5章 | 一上来就进入事件,不铺垫穿越/金手指 |
| 金手指噱头 | 分布全文,前期多后期少 | 先写主角和困境,再引出金手指 |
| 人设噱头 | 人设直接影响剧情构建 | 只有当人设本身能持续制造戏剧性时使用 |

规则:事件写法和金手指写法不能混用。

### 两种标准开篇流程

**事件开篇**:事件切入(5章)→ 嫁接主线 → 拆分目标 → 阶段性爽点循环

**主线开篇**:描写主角现状 → 营造代入感 → 描写社会环境 → 设立主角目标 → 拆分目标(设门槛)→ 绑定金手指 → 获得第一次提升 → 情绪拉扯2-3次 → 完成 → 引出下一个目标

### 噱头吸量策略

| 策略 | 做法 |
|------|------|
| 噱头延伸型 | 以开头噱头为核心,后续找类似噱头继续构建 |
| 噱头引流+常规型 | 开头噱头只负责吸量,后续走常规题材内容 |

开头噱头的功能是建立点击和追读承诺。目的达到后必须嫁接主线。

开书前评估:噱头能不能延伸出后续更多字数?不能延伸就用"噱头引流+常规"策略。

---

## 主线的正确定义

**主线不等于升级**。主线是一件事,升级是主角达成目标的行动。

| 概念 | 定义 | 示例 |
|------|------|------|
| 目标(主线) | 主角要完成的一件事 | 斗破苍穹:上云岚宗复仇 |
| 行动 | 主角为达成目标做的事 | 努力修炼、不断提升实力 |

检查主线是否符合以下特征:
- 主线是一件事,不是一个元素
- 主线完成后,要么通过铺垫开启第二条主线,要么完结
- 锚点错误会导致后续所有剧情偏差

---

## 卡文对策与剧情循环设计

### 核心公式

题材 + 金手指 + 主角身份 = 循环模式。三要素必须统一。

### 6种经典循环模式

| 模式 | 循环机制 | 循环燃料 |
|------|---------|---------|
| 案件串循环 | 案件→解谜→部分真相→更大谜团→新案件 | 信息差+推理 |
| 扮猪吃虎循环 | 默默发育→挑衅→碾压→震惊→继续发育 | 读者-角色信息差 |
| 资源积累循环 | 资源→技能→实力→新地图→新资源 | 螺旋上升 |
| 戏剧性反转循环 | 亏钱→反转赚更多→拿更多钱去亏→又赚 | 不依赖数值膨胀 |
| 组织枢纽循环 | 各自冒险→信息汇聚→衍生新剧情 | 信息交换+多线 |
| 公路片循环 | 走一段路→遇一个人→又走→又遇 | 人物塑造力 |

### 地图四势力框架

新手村(开局首张地图)必须包含四种势力形成资源闭环——这是全量框架;后续换地图可简化(见下「换地图的地图详略设计」),但变现/资源闭环渠道别丢:

1. **学校/武馆** — 学技能、提升实力
2. **商贩/药行** — 卖出收获、获取资源
3. **山贼/敌人** — 展现学习成果的靶子
4. **官府/管理机构** — 更高的上升通道

### 地位-环境同步原则

地位升高必须环境危险度升高。两者不同步 = 读者觉得无聊。

### 换地图三策略

1. **新旧地图联动**(新势力是旧势力的上级)
2. **带人走**(把重要人物带到新地图)
3. **提前铺垫吸引力**(让读者主动盼着去)

### 特殊类型处理

- **天才流**:大幅增加等级数量,防止数值几十章就崩
- **无敌流**:循环核心转为信息差(来一个秒一个,每次刷新认知)

---

## 日常文大纲框架法

### 创作路径

按以下步骤操作:

1. 从阅读中发现有趣的设定/关系/背景
2. 围绕灵感确定事业线+感情线
3. 选择节奏最快、情绪最足的节点切入
4. 对每个阶段拆分信息差+人际关系+情绪
5. 按时间顺序排列事件
6. 写作前勾勒章纲

### 大纲推演法

以"实现房东租客关系"为例:

1. 目标:实现男女主房东租客关系
2. 拆分:主角家中要有空房,距离高中近
3. 推演:有空房=家庭条件好 → 最好是贷款买,有压力
4. 逆转理由:父母上世投资失败 → 这世主角逆转 → 买学区房

### 大纲节点格式

```
事件名称:
信息差:1. 主角知道什么/配角不知道什么 2. 读者视角 vs 角色视角
人际关系与情绪:1. 主角情绪 2. 女主/配角反应 3. 负面情绪提供者
```

### 关键原则

- 大纲服务正文生成,不是枷锁
- 日常文不要有太多猛增好感的大事件,用小事件串联缓慢增长
- 事业线要和女主自身及家庭串联,达到日常和事业互相糅合

---

## 大剧情拉期待法

### 自上而下的创作逻辑

按顺序执行:

1. **明确核心和目的** — 先确定这段大剧情的爽点
2. **确定篇幅目标**
3. **设计分阶段剧情** — 围绕爽点设计若干小剧情,每个是下一个的铺垫
4. **技巧杂糅填充**

### 案例演示:参加好歌曲演唱青花瓷

**核心爽点**:主角登台演唱青花瓷,引起震撼

分阶段设计:
1. 收邀请+公园哼唱《送别》→ 震惊评委(前置暗示)
2. 彩排现场挑衅 → 制造压力
3. 节目前夜给邓紫棋写《泡沫》→ 叠加实力展示
4. 现场PK → 设备故障(加压)→ 演唱青花瓷 → 震惊
5. 评委扣分 → 分数持平(反转压制)
6. 歌手演唱主角作品 → 曝光主角是创作者(二级震惊)
7. 设备故障说明 → 得分逆袭(反转翻倍)
8. 评委要求唱《送别》→ 持续拉期待

要点:先有大爽点,再分阶段去抵达;从局部看节奏快,从整体看推进慢(只讲了一件事)。

---

## 连续性追踪与节奏管理

### 热度状态

| 状态 | 定义 |
|------|------|
| hot | 当前驱动冲突的核心元素 |
| warm | 近期活跃的元素 |
| cold | 超过安全线未触及,有被遗忘风险 |
| archived | 已完结/有意关闭的元素 |

### 有效触碰判定

以下算有效触碰:直接推进该线索、施加压力、改变关系状态、产生实际后果、交代合理的休眠原因。

不算有效触碰:纯粹提个名字、空头回调、随机提及。

### 回顾阈值

| 元素类型 | 触及间隔 |
|----------|----------|
| 核心角色 | 3-5 章 |
| 主要支线 | 4-6 章 |
| 活跃伏笔 | 2 次错过机会 |
| 不稳定关系 | 2 次出场 |

### 每章必做自检

1. 当前 hot 的元素是什么?
2. 有没有 cold 了但该 warm 的?
3. 哪些可以合理保持休眠?
4. 哪条线索的回归能加深压力?

### 失败信号(出现就要修正)

- 读者问"那个谁去哪了?"
- 重要的线索到结尾才突然冒出来
- cold 的铺垫突然变成 hot 的回报(没有预热)

### 核心冲突的节奏保护规则

1. 非大结局章节通常不解决全书核心冲突;若阶段核心冲突收束,要同步开启下一期待
2. 章末约200字宜保留悬念、决定、发现、余韵或阶段目标;低压章节不强求硬悬念
3. 局部胜利可伴随新的代价、风险或下一任务;纯奖励/低压回收章可只提供明确收益和后续期待

### 事件后的冷却章节数

| 事件类型 | 冷却(章) |
|----------|-----------|
| conflict_thrill(大冲突/打斗) | 2 |
| bond_deepening(关系深化) | 1 |
| faction_building(建立势力) | 2 |
| world_painting(世界观展开) | 3 |
| tension_escalation(压力升级) | 2 |

规则:冷却期内该类型不能作为主beat;conflict_thrill最多连续2章;每5章必须包含bond_deepening或world_painting。

### 过渡章节管理

- 单元故事结束前先把下一个目标拉出来
- 过渡章节必须维持至少一条活跃的期待线

### 换地图期待感延续

| 延续方式 | 做法 |
|----------|------|
| 复仇线 | 未完成的目标跨地图持续 |
| 旧日关系线 | 老角色在新地图出现 |
| 信息差 | 某方以为某事,实际不是 |
| 提前铺垫 | 换地图前让新地图角色/传说与主角接触 |

### 金手指的四阶段演进(基础→发展→成熟→升华)

| 阶段 | 操作要点 |
|------|---------|
| 基础 | 明确核心作用,建立读者认知 |
| 发展 | 增加新的使用方式,核心作用不变 |
| 成熟 | 与世界观深度结合,可与其他系统联动 |
| 升华 | 作用对象从个人扩到世界/天道层级,需足够伏笔支撑(签到系统:签到得物→万物可签→对人/地脉签到→签到本身成世界规则、人人信仰) |

规则:金手指重心可转移但必须有足够伏笔;可部分淡化不能完全抛弃;呈现力度应随阶段递增。

### 矛盾网设计

- 同一时刻保持2-3条矛盾线同时运行
- 矛盾线之间要有关联(因果、利益冲突、信息差)
- 每次解决一个矛盾,必须激活或加深另一个矛盾

| 层级 | 范围 | 说明 |
|------|------|------|
| 章级 | 2-3章 | 小冲突,服务于当前单元 |
| 卷级 | 一卷 | 本卷核心矛盾,卷末解决 |
| 书级 | 全书 | 终极矛盾,大结局解决 |

### "两长一短"期待法则

- 1个短期期待:当前单元的明确目标(只能有一个)
- 1-2个长期期待:远期目标预告/悬念/组织/人物

### 持续拉期待的方法

1. 在"基底期待"上添加细节,注入新的具体化需求缺口
2. 设置多个核心梗交替运行(装逼线A + 解密线B + 感情线C)
3. 设计世界观层面的"秘密"和"阴谋"

### 升级差异化管理

| 维度 | 说明 |
|------|------|
| 能力差异化 | 每个等级有质变性的新能力(炼气只能御剑→筑基能御物攻击) |
| 待遇差异化 | 不同等级获得不同的社会待遇(练气当杂役→筑基分独立洞府) |
| 人际关系网差异化 | 不同等级面对不同层次的人际关系(练气只接触师兄弟→筑基有长老正眼相待) |

如果升级前后在三个维度上没有明显差异,读者就不会有升级快感。

### 单元故事嵌套

- 在第一个单元故事高潮前,插入第二个故事的期待线
- 完成当前目标前,提前给出下一个目标的线索伏笔
- 完成当前目标后,迅速给出反转或变故,营造新期待

### 长线节奏设计

- 一卷 = 一个完整的中套娃,有自己的起承转合
- 每卷开头是代入期(5-10章),中间是发展期,结尾是高潮+收束
- 信息密度:高密度(情绪强烈、推进快)与低密度(铺垫积蓄)交替,不能一直高或一直低
- 每个低密度章节至少埋一个让读者想知道后续的点

### 期待感管理

三层期待同时运行:
- 短期期待(下一章会发生什么)
- 中期期待(这个剧情单元会怎么收)
- 长期期待(主角最终能不能达成目标)

---

## 剧情过渡与衔接

### 从一个剧情点到下一个

核心问题:主角实力提升了,但下一步干什么不清楚。解决方案:每次提升后立即引入新的挑战或目标。

### 剧情嵌套(套娃结构)

- 大套娃:整本书的主线
- 中套娃:每个卷/阶段的阶段性目标
- 小套娃:每个剧情单元的即时目标
- 三层套娃同时运行,读者始终有事可看

### 场景过渡

- 过渡场景该跳过就跳过,不拖泥带水
- 过渡不是填充,没有信息量就删掉

### 情绪衔接

- 上一场景的情绪要自然过渡到下一场景
- 不能前一个场景还在热血,下一个场景突然平淡

### 信息差衔接

- 前一个场景埋下的信息差在后一个场景回收
- 读者带着疑问进入下一场景,衔接自然有效

---

## 设门槛——拉长剧情的核心技巧

### 门槛的本质

门槛不宜只放一个敌人挡路;它应形成系统性的条件设置:主角要达成目标,必须满足一系列条件。

### 设门槛的具体方法

| 门槛类型 | 示例 |
|----------|------|
| 资源型 | 系统升级需要1000灵石,主角身上没有 |
| 成就型 | 考顶级院校需要气血值达标+独自灭杀XX级怪物+比赛前五名 |
| 多条件型 | 把一个大目标拆分成多个阶段小目标 |
| 动态门槛 | 主角资源超标时提高门槛 |
| 收集型 | 突破境界需要多件宝物 |

### 规则

- 门槛必须围绕核心卖点设计,脱离金手指和脑洞的门槛是无效剧情
- 门槛要分批提出,不要一次全甩给读者
- 每跨越一个门槛就立刻设立下一个

### 循环从设定中诞生

- 核心卖点通过"设定"体现,设定确定后自然产生反馈机制
- 设定多一条,循环元素就多一层,可写的内容就越多
- 写着写着把设定写丢了 = 把卖点写丢了

---

## 场景转换技巧

- 过渡场景没有信息量就直接跳过
- 前一个场景留下悬念,后一个场景回应悬念
- 前一个场景热血收尾,下一个场景开头要有余韵
- 切换视角时在悬念点切出,不要在平淡处切出
- 每条线在切换前都要留下一个"钩子"
- 多条线的读者关注度不同,主线占比要最大

### 换地图的深层设计

- 新地图 = 新环境 + 新角色 + 新规则 + 新目标 + 新冲突
- 换地图前:旧地图的核心冲突至少阶段性解决
- 换地图后:前5章必须快速建立新的代入感和期待感

避免以下操作:旧角色一刀切全部抛弃、新设定一次性全部倒出、新地图与旧地图毫无关联。保留至少一条贯穿主线。

每换一次地图,循环要升级:更大的规模、更高的门槛、更强的对手。

### 换地图的地图详略设计

- 换地图(非新手村)按三种势力简化铺:训练场(武馆/学校)、地头蛇(豪绅/地方势力)、破坏者(山贼/麻匪)——这是「地图四势力框架」的精简版,仍要保留变现/资源闭环渠道(商贩/药行),别只剩打斗
- 日常路线扩展关系网:上学/归还装备/逛集市作为新故事弧线的桥梁

---

## 悬念与伏笔技巧

- 小剧情伏笔:先确定结局再倒推线索,从终点往回铺确保逻辑闭合
- 整本书伏笔:大纲阶段确定关键情节,书中不断埋设细节呼应,长线伏笔要记录避免遗忘
- 谜语人vs伏笔:谜语人是故意不说明(容易惹烦);伏笔是巧妙融入剧情、自然不刻意。判定:信息延迟超过 3 章且中间无任何推进=谜语人(删或提前给);主角随口说怕水、后期落水危机才回收=伏笔
- 烧脑剧情:视角只跟主角走,不写反派视角,避免破坏悬念张力

---

## 信息团概念

- 信息团 = 一段剧情要表达的核心信息单元
- 每个信息团必须能一句话概括"这段在讲什么"
- 信息团之间要有逻辑递进关系
- 节奏快 = 信息团密集;节奏慢 = 信息团稀疏;水章节 = 信息团为零或与主线无关
- 例:一章里「主角识破骗局」是 1 个信息团,「顺带交代反派背景」是第 2 个——两个无关就是水章,砍掉第二个或让它服务第一个

---

## 自嗨判定法

### 三个自检问题(必须全部回答清楚)

1. 我这书写给谁看?(至少包括目标读者的年龄段、职业、性别、常用平台、人生处境、普遍渴望)
2. 我的目标读者群体在看网文时希望看到什么内容?
3. 我的这本书有哪些内容是目标读者群体想看的?

判定标准:三问有一问答不好 = 自嗨。立刻停下修正。

### 纠正方法

- 分析同类书的读者评论,提取高频正面关键词
- 对比同类书中高互动与低互动段落的差异特征
- 用同类书的目标读者画像反向校验本书的情节选择

---

## 质量检查清单

每次完成一段剧情设计或一卷大纲后,逐项检查:

### 基础完整性
- [ ] 主线是否明确为"一件事"(避免写成元素罗列或升级条)
- [ ] 循环模式是否与题材+金手指+主角身份统一
- [ ] 三层套娃(大/中/小)是否同时运行

### 节奏与期待
- [ ] 三层期待(短/中/长)是否同时在线
- [ ] 章末约200字是否保留悬念、决定、发现、余韵或阶段目标
- [ ] 信息密度是否有高低交替(不能一直高或一直低)
- [ ] 冷却期规则是否遵守

### 连续性
- [ ] hot/warm/cold 状态是否正常(无该 warm 却 cold 的元素)
- [ ] 核心角色 3-5 章、主要支线 4-6 章内是否有有效触碰
- [ ] 换地图/换阶段后是否保留至少一条贯穿主线

### 高潮与过渡
- [ ] 高潮是否需要假胜(先给希望再击碎);若不用,是否有其他情绪落差/兑现方式
- [ ] 胜利是否需要代价/风险/下一任务;若是纯奖励章,收益是否足够明确
- [ ] 过渡场景是否删掉了无信息量的部分
- [ ] 场景切换是否在悬念点切出

### 避坑检查
- [ ] 门槛是否围绕核心卖点设计(非无效剧情)
- [ ] 金手指是否按四阶段演进(未跳跃、未抛弃)
- [ ] 自嗨三问是否全部能回答清楚
- [ ] 信息团是否每段都能一句话概括"在讲什么"
references/quality-checklist.md

# 网文质量检查清单

## 目录

- [一、通用检查](#一通用检查)
- [二、长篇专项](#二长篇专项)
- [三、短篇专项](#三短篇专项)

## 一、通用检查

### 章节结构
- [ ] 开头有钩子(不是天气/风景/日常开场)
- [ ] 中段有推进(快节奏题材要有可见事件;慢热题材状态变化即可)
- [ ] 局势有变化(读完这章,世界跟之前不一样了)
- [ ] 结尾落在变化上(不是总结)

### 开篇检查(前 300-500 字)
- [ ] 有钩子,能抓住注意力
- [ ] 不从天气/风景/日常开始
- [ ] 主角快速出场
- [ ] 卖点或危机可见

### 章节推进
- [ ] 本章至少改变目标 / 风险 / 信息 / 关系 / 资源 / 身份 / 情绪立场七类状态之一;快节奏(番茄/爽文)应有可见事件或爽点,慢热/情感文才可只靠低压小变化达标,不强求每章高潮
- [ ] 不是水字数(删掉这章会损失哪项状态变化?答不出才需压缩)
- [ ] 任务卡点有用:卡住之后是否带来上述状态变化?
- [ ] 推进强弱是否符合本书已建立的题材、兑现节奏与对标,而非套固定通用字数

### 信息传递
- [ ] 没有大段设定说明文
- [ ] 信息跟着冲突走(通过事件传递设定)
- [ ] 设定量可控(一章不超 3 个新概念)

### 场景检查
- [ ] 场景有目标(人物要什么)
- [ ] 场景有阻碍(什么挡着)
- [ ] 场景有变化(结束后跟之前不同)
- [ ] 人物在做事情,不是在感觉事情
- [ ] 没有可删除的段落

### 章尾
- [ ] 结尾落在变化上
- [ ] 有危机/决定/发现/反转中的至少一个
- [ ] 不是总结式结尾
- [ ] 拉住读者翻下一页

### 语言
- [ ] 没有空洞的抒情段落
- [ ] 没有连续多段同一情绪
- [ ] 对话符合人物身份(不同人说话方式不同)
- [ ] 标点节奏符合语气和人物声线:没有通篇句号化,也没有随机堆砌问号/感叹号;正文(含对话)不残留 `……`/`——`,迟疑或打断已改为动作、短句、逗号或句号
- [ ] 情绪通过动作落地(不是直接说"他很难过")
- [ ] 标题行以外没有章节/写作元信息:不得出现 `第[一二三四五六七八九十百千万两0-9]+章|上一章|上章|前一章|本章|这一章|前文|后文|伏笔|细纲|读者` 这类词;需要承接前文时改成角色能感知的事件锚点或相对时间;故事内真实阅读/讨论“第X章”或真实读者身份语境除外

### 连载连续性
- [ ] 没有遗忘之前的承诺/伏笔
- [ ] 没有突然塞入大量新设定
- [ ] 伏笔有推进
- [ ] 故事引擎还在运转

### 水(filler)检测
以下信号出现 = 可能水了:
- 全章对话没有任何新信息
- 同一个情绪写了 3 段以上
- 场景描写超过 500 字但不推进剧情
- 角色回忆之前发生的事但没有任何新视角
- 连续 2 章以上没有冲突
- 删掉无损的流程细节 / 任务卡点 = 水;压缩或删除

---

## 二、长篇专项

### 黄金三章检查
- [ ] 第一章前 500 字有钩子?
- [ ] 主角第一章就出场?
- [ ] 第一章有事件(不是纯铺垫)
- [ ] 第二章有升级(矛盾加深)
- [ ] 第三章有追读理由(给出继续看的动力)
- [ ] 前三章有至少 2 个爽点?
- [ ] 世界观没有大段说明文?
- [ ] 每章结尾有往下看的理由?(低压/过场章弱钩子或阶段目标即可,不强求强悬念)

### 节奏检查
- [ ] 最近 5 章是否有明确进展?
- [ ] 爽点间隔是否超过 5000 字?(按章节定位:高压/推进章查间隔;低压/关系/信息整理章不计入)
- [ ] 有没有连续 2 章以上没有冲突?(呼吸章正常;红线是连续两章压力级≤1 或相邻章情绪趋同)
- [ ] 一卷是否有章节定位高低层次,没有全程同一力度?低压+过场是否克制(合计不超约 15%)?

### 人物检查
- [ ] 主角行为符合人设?
- [ ] 配角是否有存在感?
- [ ] 反派逼格是否匹配当前阶段?

### 五维评分标准

每个维度 0-100 分,根据评分结果选择精修策略。

### 维度 1:核心一致度
检查:关键冲突、关键行动、人物动机是否前后一致。

| 问题 | 严重度 | 修复 |
|------|--------|------|
| 人物动机突然改变无铺垫 | critical | 补充动机转变的触发事件 |
| 核心冲突前后不一致 | high | 回溯修改冲突设定 |
| 关键行动与人物性格矛盾 | high | 调整行动或补充解释 |
| 次要矛盾遗忘 | medium | 回收或弱化 |

### 维度 2:表层重写度
检查:句式与措辞是否足够原创,避免套路化表达。

| 问题 | 严重度 | 修复 |
|------|--------|------|
| 照搬原句导致语气不自然 | medium | 只在确有 AI 腔时改写,正常原句可保留 |
| 大量使用 AI 标志词 | high | 替换为具体描述 |
| 同一句式重复出现 | medium | 变换表达方式 |
| 描写过于文学化 | medium | 改为口语化/动作化 |

### 维度 3:格式一致度
检查:段落是否按戏剧单元/镜头自然断开,主语/角色名节奏是否自然,开头结尾格式是否统一。

| 问题 | 严重度 | 修复 |
|------|--------|------|
| 机械按字数切段或主语过密 | medium | 按戏剧单元重排段落,段首点名、段中代词/省略、关键转折再点名 |
| 章节字数偏离目标 | **high** | 写作/大纲修复时先回到细纲补足计划内情节点,再展开;去AI味已有正文时不得新增剧情 |
| 格式混乱(对话/描写不统一)| low | 统一格式 |

### 维度 4:可读性
检查:是否有啰嗦、AI 腔、空泛总结、套路修辞。

| AI 腔特征 | 如何识别 | 如何修复 |
|-----------|----------|----------|
| 空泛总结 | 「他终于明白了」「一切尽在不言中」 | 删掉,用行动代替 |
| 套路修辞 | 「命运仿佛在和他开玩笑」 | 删掉或换成具体描述 |
| 比喻堆叠 | 连续多处「像/好像/仿佛/如同」,每个画面都靠比喻解释 | 只留最有叙事功能的少数比喻,其余改成动作、物件、声音或后果 |
| 情绪标签 | 「他感到一阵悲伤」 | 改为行为表现 |
| 大段心理描写 | 连续 3 句以上内心独白 | 压缩为 1 句或转为对话 |

### 维度 5:逻辑连贯
检查:句间/段间是否通顺,有无设定冲突。

| 问题 | 严重度 | 修复 |
|------|--------|------|
| 设定前后矛盾 | critical | 查找并统一设定 |
| 时间线错误 | high | 标记时间线并修正 |
| 角色信息不一致 | high | 建立角色档案对照 |
| 因果链断裂 | medium | 补充过渡 |

### 精修策略

根据五维评分结果,选择精修策略:

| 主要问题 | 策略 | 说明 |
|----------|------|------|
| 核心一致度低 | rewrite | 围绕核心冲突重写相关段落 |
| 字数超标 | compress | 删减不推动剧情的内容 |
| 无意义卡点 | compress | 保留卡出的变化,压掉流程 |
| AI 腔重 | de_ai | 替换禁用词、改写句式 |
| 小问题多 | polish | 打磨语言细节 |

### 读者契约与终局储备双向审查

审稿先问“无毒吗”,再问“好看吗”:**无毒不等于好看**,没有 契约破坏 只代表不踩雷,还要确认情绪兑现足够强。风险标记三档:契约安全(与开篇承诺一致、主角保有关键选择、收益归属清楚)/ 需补强(可能稀释高光或推进过快,但能靠铺垫、交换、代价修复)/ 契约破坏(核心卖点被没收或无关角色夺走期待所有权,需修纲)。

- [ ] 读者契约:本章/本卷是否兑现开篇承诺,还是把核心卖点交给机构、配角或偶然性?风险标记:契约安全 / 需补强 / 契约破坏。
- [ ] 主角代理权:按因果权 + 结算权审查,而非按出场时长;关键节点核对四问——谁决定事情为什么发生、谁作出不可替代的选择、谁承担或选择关键后果、核心收益/认可/权力结算给谁。配角可执行局部动作,但不能无声夺走期待所有权。
- [ ] 期待债:承诺是否被偿还、延期并支付利息,还是被解释/流程/设定吞掉?
- [ ] 终局储备:本单元是否动用了本阶段还不该解锁的终局底牌(宿敌/真相/身份/金手指上限)?是否让某条升级线逼近天花板、后面没台阶接?两问皆否即放行——单章一战多得(多线齐涨)是好设计,不算超速。
- [ ] 兑现归属:期待所有权是否按承诺结算;自愿让渡只有在“长期净收益为负 + 控制/因果权下降”同时出现时亮红灯,战略性让步的更大控制/未来收益/权力/信息交换是否可见?
- [ ] 节奏续接:高潮/兑现后是否允许短暂低压和小而可见的收益/奖励,而非机械立即加危机?新元素是否支付换书债、没有逃避旧承诺?
- [ ] 题材特定风险:履约爽文/能力幻想中,主角是否反复以愚蠢或可避免的无能制造灾难并由他人收拾,损害因果权与结算信任?
- [ ] 反向审查:若删除“安全解释”,情绪是否更强?若保留风险情节,是否带来更强期待、代价或爽点?
- [ ] 综合线:契约、主角代理权、期待债、终局储备是否互相支撑,而不是只做到“无毒”。

---

## 三、短篇专项

### 虐爽节奏检查

短篇特有的检查项——虐点和爽点的分布是否合理:

```
理想分布:虐1 → 虐2 → 虐3 → 爽1(小) → 虐4 → 爽2(大)
禁忌分布:虐1 → 虐2 → 虐3 → 虐4 → 虐5(无爽点,读者流失)
禁忌分布:爽1 → 爽2 → 爽3 → 爽4(无铺垫,爽感疲劳)
```

检查规则:
- 每 300 字至少 1 个小冲突
- 虐点间隔不超过全文的 30%
- 最大爽点必须出现在全文的 70-85% 位置
- 结尾必须有情绪落点

### 对话密度检查

| 指标 | 合格标准 | 警戒线 |
|------|----------|--------|
| 对话占全文比 | 45-65% | <30%(太干)或>75%(太水) |
| 每章对话条数 | 12-18条/千字 | <8条(缺交互)或>25条(碎片化) |
| 伤人性对话占比 | 35-45%(虐文)/ 20-30%(爽文) | 虐文<20%(不够痛)/ 爽文>40%(太压抑) |

### 主角冷静度检查(打脸/复仇文专用)

| 检查项 | 标准 |
|--------|------|
| 主角是否有标志性冷静动作? | 必须有(端水杯/整理西装/转笔等) |
| 主角是否有情绪失控场面? | 复仇文最多1次,且必须在前30% |
| 反派是否比主角更歇斯底里? | 必须是——反差是爽感来源 |
| 主角台词是否短于反派? | 平均短30-50%——越短越有力 |
| 是否有"审判式对话"? | 至少2处(主角提问→对方自爆) |

### 证据链完整性检查(复仇/打脸文专用)

| 检查项 | 标准 |
|--------|------|
| 证据是否分章释放? | 至少分3次揭露,不能一次全给 |
| 每个证据是否有铺垫? | 前文必须埋过线索 |
| 反派是否每次都先得意再被打脸? | 必须是——先扬后抑 |
| 最终证据是否最致命? | 最后一个证据要改变全局认知 |
| 是否有"定时炸弹"证据? | 至少1个主角提前布局的证据 |

### 毒点/代入感/震惊/开头/结尾/情绪/期待感速查

> 详细的毒点分类、识别方法和修复方案见 `anti-ai-writing.md`。

| 检查项 | 标准 |
|--------|------|
| 爽文不爽 | 金手指效果必须展示清楚 |
| 压制无目的 | 每次压制必须服务于后续爆发 |
| 反派结局与主角无关 | 反派之死必须因主角行动导致 |
| 经济/战力崩坏 | 设定前后一致,以普通人为锚点 |
| 女主/女配降智 | 人设正常可写,老套路别写 |
| 读者期待长时间不满足 | 适时满足或引入新期待 |
| 主角行为不可理解 | 必须能理解、能共鸣 |
| 氛围被突兀梗破坏 | 氛围 > 突兀梗(乐子文除外) |
| 钩子不回收 | 钩子不回收 = 烂尾 |
| 震惊分层 | 点→网→深度,不能只写点震惊 |
| 震惊阶梯递进 | 阶梯式上升,非直线 |
| 震惊有广度 | 关系网都要有反应 |
| 前50字有冲突/异常 | 不能是背景铺垫 |
| 前100字知核心矛盾 | 必须知道 |
| 开头情绪强度 | ≥7(1-10) |
| 结尾是具体的 | 动作/对话/画面,禁止总结/反思 |
| 结尾有余韵 | 读者还想往下看 |
| 结尾情绪强度 | 虐≥8,爽≥7,治愈≥6 |
| 情绪对等释放 | 虐多少补多少,反派结局关联主角 |
| 期待管理 | 两长一短不断,核心卖点贯穿 |
references/quality-rubric.md
# 通用网文内容审查 Rubric

> 用途:当用户未指定番茄、起点、知乎盐言等目标平台时,作为 `/story-review` 的默认小说内容评分标准。它评估的是**故事文本质量**,不是 skill/plugin 实现质量。

## 评分方法

每项按 PASS / WARN / FAIL 标记,并把 FAIL/WARN 转成统一 Findings Schema:
- 影响主线、角色动机、世界规则或读者信任 → S1
- 明显影响留存、节奏、章节效果或人物可信度 → S2
- 局部质量、格式、措辞、轻微节奏问题 → S3
- 风格建议或可选增强 → S4

## 核心维度

| 维度 | PASS | WARN | FAIL |
|---|---|---|---|
| 核心卖点 | 章节围绕明确卖点推进,读者知道为什么继续看 | 卖点存在但表达弱或被支线稀释 | 看不出本章服务什么卖点或主线 |
| 冲突推进 | 本章有明确冲突、阻碍、选择或代价 | 冲突较轻,推进感不足 | 主要是解释/闲聊/总结,无实际推进 |
| 任务卡点 | 角色办事被卡住,并卡出信息、关系、代价、选择或伏笔变化 | 有卡点但变化偏弱,可压缩 | 卡点只是流程细节,删掉不影响故事 |
| 情绪曲线 | 有铺垫、升温、释放或反转 | 情绪有变化但节点不清 | 情绪平直或突兀转向 |
| 钩子与期待 | 开头/结尾至少一处建立期待 | 钩子弱但仍有后续问题 | 没有悬念、目标或未完成期待 |
| 角色动机 | 行为符合目标、性格、处境和关系压力 | 局部行为缺少铺垫 | 角色为推动剧情而失真 |
| 对话质量 | 对话有潜台词、信息控制和角色差异 | 有信息堆叠或声音略同质 | 对话像说明书,人人同腔 |
| 设定一致性 | 不违背既有规则、时间线和角色属性 | 有轻微模糊点需补证据 | 与已写设定或前文事实冲突 |
| 文字自然度 | 具体、可感、动作承载信息 | 偶有套话或抽象总结 | AI 腔、陈词滥调、总结体明显 |
| 格式可读性 | 段落按戏剧单元/镜头自然断开,对话独立,无多余空行,主语节奏自然 | 局部段落偏长/偏碎或主语重复略多 | 大段堆叠、机械切段、对话/叙述难读、主语过密 |
| 剧情循环 | 目标→阻碍→行动→代价/反馈→新期待闭环清晰 | 有循环但缺一环或反馈偏弱 | 无目标、无阻碍或无反馈,读者不知道局势如何变化 |
| 高潮构建 | 蓄能→假胜→崩解→反转/兑现有层次 | 有爆点但蓄能或兑现不足 | 平铺直给、无代价、无兑现或情绪落空 |
| 关系进展 | 互动尺度匹配关系阶段,越界有铺垫 | 局部推进略快但可补证据 | 突然亲密/信任/敌对,角色关系跳变 |
| 伏笔状态 | 伏笔埋设、状态和回收路径可追踪 | 伏笔偏密/偏疏但不影响理解 | 伏笔与设定冲突、断线或造成主线理解混乱 |

## 黄金三问

1. **读者为什么翻下一页?** 如果答不出,至少 S2。
2. **本章改变了什么?** 情节、关系、信息、情绪至少改变一项;否则至少 S2。
3. **哪个证据支持你的判断?** 没有原文证据的 finding 不输出,改为“证据不足”。

## 发布建议门槛

| 综合情况 | Verdict |
|---|---|
| 无 S1/S2,S3 可快速处理 | APPROVE |
| 有 S2,或 S3 数量多影响阅读 | CONCERNS |
| 有 S1,或核心卖点/动机/规则崩坏 | REJECT |

## 输出要求

- 所有问题必须包含 severity、category、location、evidence、issue、fix。
- 先列 S1/S2,再列 S3/S4。
- 不要只给泛泛评价;每条 finding 必须能指导下一轮修改。
- `consistency` / `factual` 类 finding 的 fix 只写事实统一方向,不写文学创作建议。
references/rubrics/fanqie.md
# 番茄小说 Quality Rubric

## 评分标准

| 指标 | PASS 标准 | FAIL 标准 |
|------|----------|----------|
| 开头吸引力 | 前 3 段包含冲突/悬念/钩子 | 前 3 段纯描写/背景介绍 |
| 读者留存 | 每章结尾有"翻页动力"(悬念/反转/新信息) | 连续 3 章结尾无翻页动力 |
| 题材匹配 | 内容符合标签类型(如甜宠/逆袭/重生) | 偏离标签类型超过 3 章 |
| 算法友好 | 短段落、快节奏、高信息密度 | 大段落、慢节奏、低信息密度 |
| 情绪节点 | 每 1000 字有情绪起伏 | 连续 2000 字情绪平直 |
| 完读率预估 | 3 章 Hook 有效 + 节奏不拖 → 预估完读 >40% | 前 3 章无 Hook 或连续拖沓 → 预估完读 <20% |

## 使用方式

- story-review 的 story-architect 视角引用此标准
- advisory only,不 blocking
- 番茄算法偏好快节奏、强情绪
references/rubrics/qidian.md
# 起点中文网 Quality Rubric

## 评分标准

| 指标 | PASS 标准 | FAIL 标准 |
|------|----------|----------|
| 爽点密度 | 每 3000 字 ≥1 情绪节点(升级/打脸/获宝) | 连续 5000 字无情绪节点 |
| 升级节奏 | 每 50 章有等级/实力突破 | 100 章无任何升级 |
| 金手指使用 | 每章提及或使用金手指(系统/戒指/功法) | 连续 5 章未提及金手指 |
| 日更支持 | 大纲支持 4000 字/天节奏(每章 2000 字,每天 2 章) | 无日更计划,章节长度不规律 |
| 钩子密度 | 每章结尾有悬念/钩子 | 连续 3 章结尾无钩子 |
| 角色存在感 | 主角每章出场并推进 | 连续 2 章主角不出场或无推进 |
| 追读比 | 章尾钩子 + 情绪连续性 → 追读比预估 >30% | 连续 3 章无钩子或情绪断裂 → 追读比预估 <15% |

## 使用方式

- story-review 的 story-architect 视角引用此标准
- advisory only,不 blocking
- 用户可自定义阈值
references/rubrics/zhihu.md
# 知乎盐言故事 Quality Rubric

## 评分标准

| 指标 | PASS 标准 | FAIL 标准 |
|------|----------|----------|
| 第一人称 | 全文使用「我」视角 | 使用第三人称(除非多视角设计) |
| 情绪拉扯 | 每段有情绪变化(期待→失望→惊喜等) | 连续 3 段情绪无变化 |
| 反转设计 | 至少 1 个有效反转(前文有伏笔) | 无反转或反转无伏笔支撑 |
| 结尾余韵 | 结尾留有思考空间/情感余韵 | 结尾完全封闭无回味 |
| 格式合规 | 段落以短为主(有长短/疏密变化)、无空行、对话格式正确 | 通篇同长度或连续超长段落/空行/对话标签 |
| 开头钩子 | 第一句话包含信息量(动作/冲突/悬念) | 第一句话纯描写/背景 |
| 字数控制 | 8000-13000 字(盐言标准) | 超出或不足 |
| 开头跳失率 | 第一句包含动作/冲突/悬念 → 跳失率预估 <30% | 第一句纯描写/背景 → 跳失率预估 >50% |

## 使用方式

- story-review 的 story-architect 和 character-designer 视角引用此标准
- advisory only,不 blocking
- 盐言故事注重第一人称代入感和情绪拉扯
scripts/check-ai-patterns.js
#!/usr/bin/env node
'use strict';

const fs = require('fs');
const path = require('path');

const USAGE = `Usage: node check-ai-patterns.js [--check] [--json] [--fail-on=blocking|all] <file...>

Detect high-risk AI-flavor prose patterns that need human rewrite:
  - negative setup followed by positive flip in the same sentence
  - comma/semicolon/colon + positive flip
  - sentence break + positive flip
  - repeated negative setup followed by positive flip
  - em-dash (按功能改写), 碎句号 (连续短叙述句), 长段落 (按镜头断段)
  - 微动作复读 (「了下/了一下」式轻量补语高密度,电报体指纹)
  - 抽象总结复读 (命运/棋局/这一刻终于明白/才刚刚开始,AI 结尾腔)
  - 套词密度过高 (仿佛/一丝/深吸一口气/平静无波等禁用词聚集)
  - 比喻密度过高 (像/好像/仿佛/如同等比喻标记成片复现)
  - 解释链密度过高 (知道/明白/这意味着/必须/需要等判断链聚集)
  - 系统公告公文腔过密 (方括号系统/规则行里硬规则词聚集)
  - 过度精炼短段 (长文本里短叙述段过密且自然连接偏少)
  - 低连接密度 (引号外叙述功能词/白话连接偏少且中长句不足,像提纲/电报体)
  - 监控摄像头式动作清单 (同段连续摆放动作动词,缺少视角温度/情绪缓冲)
  - 音量反差腔 (声音不高/不大…却…, 实战漏网句式)
  - 否定排比 (没有X,没有Y…连排 / 没X…只是Y 先否定后肯定, 实战漏网句式)
  - 反序对比 (是A,不是B — not-is 的反序变种, 实战漏网句式)
  - 预告式总结收尾 (文末窗口 没人知道/才刚刚开始/正朝着…压了过去, 实战漏网句式)
  - 引号强调滥用 (叙述里 1-4 字短词加引号强调,密度型)

Each finding carries severity: blocking by default for generation/deslop cleanup (not-is-comparison / em-dash / voice-contrast / negation-parade / reverse-not-is / trailer-ending). This is a local style/readability gate, not an AIGC detector score; functional human text can be marked for review instead of hard-edited for a detector.
或 advisory (period-stutter / long-paragraph / micro-action-tic / action-list-tic / abstract-summary-tic / cliche-density-tic / metaphor-density-tic / reasoning-chain-tic / system-notice-formality-tic / overcompressed-prose-tic / low-connective-density-tic / quote-emphasis-tic,是提示,justified 的长推理/氛围段可保留)。
--fail-on=blocking 只在出现 blocking finding 时退出 1;默认 --fail-on=all 有任何 finding 即退出 1。

The script reports findings only. It never rewrites text, because the safe fix is
contextual: usually delete the negative setup, write the positive term directly,
or show it via action/detail.`;

const STOP_CHARS = new Set(['。', '!', '?', '!', '?', '\n']);
const SOFT_SEPARATORS = new Set([',', ',', '、', ';', ';', ':', ':']);
const HARD_SEPARATORS = new Set(['。', '.', '!', '!', '?', '?']);
const MAX_NEGATIVE_SPAN = 80;
const MAX_POSITIVE_SPAN = 80;

// 碎句号:连续 STUTTER_MIN_RUN 个「叙述」短句(每句可见字数 ≤ STUTTER_MAX_SENTENCE)无呼吸。
// 只数叙述句,跳过对话/弹幕/系统播报(成片短句是这些体裁的正常形态,不算碎句号)。
const STUTTER_MIN_RUN = 6;
const STUTTER_MAX_SENTENCE = 5;
// 长段落:单段原始字符数超过阈值即提示按镜头断段(手机阅读保守阈值,正常单段远低于此)。
const LONG_PARAGRAPH_CHARS = 200;

// 微动作复读:「V了下 / V了一下 / 拍了两下 / 松了半圈」式轻量补语在叙述里高密度复现,
// 容易形成删减过头的电报体指纹。只扫引号外叙述;密度与次数双门槛同时达标才报,
// 单次出现是正常中文。
const MICRO_TIC_PATTERN = /了(?:[一两三几半])?[下阵圈道声眼口气会]/g;
const MICRO_TIC_MIN_HITS = 5;
const MICRO_TIC_PER_KILO = 6;

// 监控摄像头式动作清单:同一段连续堆叠通用动作动词(伸手/拿起/取过/挑开/放下/转身等),
// 且用逗号/顿号串联成步骤表时,读感像无视角温度的监控记录。只做 advisory;
// 打斗/追逐等功能性动作编排可保留或人工复核。
const ACTION_LIST_VERB_PATTERN = /伸手|抬手|探手|拿起|拿过|取出|取过|掏出|摸出|抓起|攥住|握住|捏住|按住|推开|拉开|打开|关上|放下|递给|挑开|掀开|扯开|拧开|倒出|端起|转身|回头|抬头|低头|弯腰|俯身|走到|走向|坐下|站起|看向|看着|盯着|扫过/g;
const ACTION_LIST_MIN_HITS = 5;
const ACTION_LIST_MIN_SEPARATORS = 4;

// 抽象总结复读:模板化段落常把角色当下经历拔成「命运/棋局/
// 这一刻终于明白/才刚刚开始」的作者总结。单个词可能服务题材;高密度聚集才报。
const ABSTRACT_SUMMARY_PATTERNS = [
  /这一刻[,,]?[^\n。!?!?]{0,24}(?:终于|才)(?:明白|意识到)/g,
  /从这一刻开始/g,
  /(?:命运|宿命)[^\n。!?!?]{0,28}(?:齿轮|棋局|獠牙|改写|推向|安排)/g,
  /早已[^\n。!?!?]{0,8}(?:布好|安排好)[^\n。!?!?]{0,8}(?:棋局|局)/g,
  /前所未有的(?:决意|清醒|勇气|力量|恐惧|平静|信念)/g,
  /(?:反击|复仇|战争|较量|故事|命运)[^\n。!?!?]{0,12}才刚刚开始/g,
  /(?:新的开始|全新的开始)/g,
];
const ABSTRACT_SUMMARY_MIN_HITS = 3;
const ABSTRACT_SUMMARY_PER_KILO = 4;

// 套词密度:单个「仿佛/一丝」可能是正常中文,高密度聚集才会形成模板腔。
// 词表只收本 repo banned-words 中已明确标为高危的形态,避免把普通功能词一网打尽。
const CLICHE_PATTERNS = [
  /仿佛|犹如|宛若|如同/g,
  /一丝|一抹|些许|几分|隐约/g,
  /深吸一口气|缓缓|微微|轻轻|淡淡/g,
  /眼中闪过|嘴角勾起|眸光微微一闪|指节泛白|目光锐利|眼神锐利/g,
  /心中涌起一股|心头一震|心中一动|心下了然|心中暗道|心中一凛/g,
  /不容置疑|不容置喙|不易察觉|显而易见|毫无疑问|不可否认/g,
  /声音不大[,,]?却带着|语气平静无波|平静无波|声音平直|听不出情绪/g,
  /不知何时|唾手可得|无声翻涌|沉默(?:在[^。!?!?\n]{0,16})?蔓延|难以言说/g,
  /散发着一股|冰冷的光|格外刺眼|深邃而冰冷/g,
];
const CLICHE_DENSITY_MIN_HITS = 8;
const CLICHE_DENSITY_PER_KILO = 12;

// 比喻密度:单个生活化比喻可服务画面;“像/好像/仿佛/如同”成片复现时,
// 容易变成 AI 式修辞堆叠。只做 advisory,修法是删到必要数量并回到具体画面,
// 不是把“像”换成另一组比喻词。
const METAPHOR_MARKER_PATTERN = /好像|像是|仿佛|宛如|如同|犹如|(?<![不头图画影录摄肖])像(?![头像素])/g;
const METAPHOR_LIKE_PHRASE_PATTERN = /(?:死|水|冰|火|潮水|石头|木头|机器|纸|铁|鬼|死人|刀|针|网|墙)一样/g;
const METAPHOR_DENSITY_MIN_HITS = 7;
const METAPHOR_DENSITY_PER_KILO = 3;

// 解释链密度:常见“他知道/他明白/这意味着/必须需要”
// 连续替读者推理,读感像报告。单个判断词可服务推理;高密度聚集才提示回到角色当下证据。
const REASONING_CHAIN_PATTERNS = [
  { key: 'mental', core: true, pattern: /(?<![不没未无])(?:他|她|我)?(?:知道|明白|意识到|清楚|判断|确认|分析)/g },
  { key: 'connector', core: true, pattern: /这意味着|也就是说|换句话说|真正的问题(?:在于)?|问题在于|关键在于|在这种情况下|按照这个逻辑|只有这样|想到这里/g },
  { key: 'modal', core: true, pattern: /(?:(?<!不)(?:必须|需要|应该|只要|就会|可能|可以|能够|无法)|不能)[^。!?!?\n]{0,16}(?:判断|确认|承担|维持|稳住|控制|扩大|失控|带来|造成|理解|默认|回家|进门|核对|筛选|减少|建立|风险|结果|秩序|责任)/g },
  { key: 'abstract', core: false, pattern: /(?:任务|条件|风险|来源|逻辑|局面|结果|责任|秩序|规则|信息不足|决策能力)/g },
];
const REASONING_CHAIN_MIN_HITS = 8;
const REASONING_CHAIN_CORE_MIN_HITS = 4;
const REASONING_CHAIN_MIN_BUCKETS = 2;
const REASONING_CHAIN_PER_KILO = 18;

// 系统公告公文腔:只看成片方括号规则/面板行里的硬规则词。
// 这不是特定题材词表;单条严肃规则、日常叙述或普通对话不触发。
const NOTICE_FORMAL_PATTERNS = [
  /不得|必须|不可|禁止|严禁|应当|须|需|务必/g,
  /当前|本公告|本规则|本系统|提示|任务失败|临时权限|权限|状态|等级/g,
  /维持|公共区域|秩序|优先|惩罚|处罚|违规|指令|执行/g,
  /被视为|同样计入|计入|承担|责任|单位|撤回|转发|截图/g,
];
const NOTICE_FORMAL_CORE_PATTERN = /不得|必须|不可|禁止|严禁|应当|须|需|务必|被视为|同样计入|计入/g;
const NOTICE_FORMAL_MIN_LINES = 4;
const NOTICE_FORMAL_MIN_HITS = 12;
const NOTICE_FORMAL_CORE_MIN_HITS = 5;
const NOTICE_FORMAL_PER_KILO = 60;

// 过度精炼短段:过度处理样本里常见大量 15 字以内叙述段,且“的/了/就/着/过/呢/吧/啊”等
// 自然连接偏少;对照文本通常保留更多自然连接。此项只做 advisory,禁止机械注水。
const OVERCOMPRESSED_PROSE_PARTICLE_PATTERN = /[的了就着过呢吧啊呀嘛]/g;
const OVERCOMPRESSED_PROSE_MIN_CHARS = 1200;
const OVERCOMPRESSED_PROSE_MIN_PARAS = 45;
const OVERCOMPRESSED_PROSE_SHORT_MAX_CHARS = 15;
const OVERCOMPRESSED_PROSE_SHORT_RATIO = 0.58;
const OVERCOMPRESSED_PROSE_PARTICLE_PER_KILO = 85;

// 低连接密度:单纯低功能词会误抓有大量中长句的文本;
// 因此必须叠加“中长句不足”,并只看引号外叙述。这是 overcompressed 的短窗口补充,只做 advisory。
const LOW_CONNECTIVE_FUNCTION_TERMS = ['的', '了', '就', '在', '是', '也', '都', '还', '又', '把', '被', '给', '这个', '那个', '里面', '以后', '时候', '现在', '因为', '所以', '但是', '不过', '然后', '已经', '还是', '起来', '出来', '下去'];
const LOW_CONNECTIVE_PLAIN_TERMS = ['的', '了', '就', '也', '还', '又', '这个', '那个', '东西', '事情', '时候', '里面', '以后', '一下', '一点', '有点', '还是'];
const LOW_CONNECTIVE_MIN_CHARS = 800;
const LOW_CONNECTIVE_FUNCTION_PER_KILO = 100;
const LOW_CONNECTIVE_PLAIN_PER_KILO = 65;
const LOW_CONNECTIVE_LONG_SENTENCE_CHARS = 30;
const LOW_CONNECTIVE_LONG_SENTENCE_RATIO = 0.08;

// either-or「不是A就是B / 不是A也是B」里紧贴的「是」是连词的一部分,不是肯定项系动词。
// 含「不」以沿用「不是A,也不是B」第二个否定段不算翻转的旧排除。
const COMPACT_EITHER_OR_PREV = new Set(['不', '就', '也']);
// 句尾语气/反问助词;「…,是吗 / 是吧 / 是嘛」是反问尾巴,不是否定后的肯定翻转。
const TAG_PARTICLES = new Set(['吗', '吧', '嘛']);
// 段首确认语;「不是第一次来。是的,他还记得……」里的「是的/是啊」
// 是承接确认,不是「不是 A,是 B」的肯定翻转。
const AFFIRMATION_TAG_PARTICLES = new Set(['的', '啊', '呀', '呢']);
const AFFIRMATION_TAG_BOUNDARY = new Set(['', ',', ',', '。', '.', '!', '!', '?', '?', '、', ';', ';', ':', ':', '\n', '\r', '\t', ' ']);

// 成对引号(台词/系统播报/弹幕)的字符对,stripQuoted 与 quotedRanges 共用一份来源。
const QUOTE_PAIRS = [['「', '」'], ['『', '』'], ['【', '】'], ['“', '”'], ['‘', '’'], ['"', '"'], ["'", "'"]];
const QUOTE_SOURCES = QUOTE_PAIRS.map(([open, close]) => `${escapeRegExp(open)}[^${escapeRegExpCharClass(close)}]*${escapeRegExp(close)}`);

// ---- 实战测试漏网句式(来源:实战写作抓到的真实漏网例句;2026-07 校准)----
// 校准基线:《万疆》真人正文 20 章(第1/10/20/…/190章)+ demo 前 20 章。
// blocking 规则要求真人语料命中 ≈0(每 20 章 ≤1 处且人工判定确属该句式);数据见各规则注释。

// 音量反差腔(实战漏网 A):「声音不高,第一句却稳稳压住了整个大厅。」
// 旧网只有套词密度桶里的「声音不大,却带着」,音量词/转折词一换就漏。
// 引号外叙述逐处 blocking;修法是删掉音量铺垫,直接写声音落进场子的具体效果。
// 校准:《万疆》20 章 0 命中,demo 前 20 章 0 命中。
const VOICE_CONTRAST_PATTERN = /声音(?:并)?不[大高响亮][^。!?!?\n]{0,16}[却但偏]/g;

// 否定排比(实战漏网 B):「没有伴奏,没有和声,没有提词器。」同句 ≥2 个「没有X,」连排;
// 变体「他没炫技,没有那种…架势。他只是唱」先否定铺垫、再用「只是/只会/只有」收肯定。
// 只收「没/没有」段,不收「不X」段——真人叙述里「不哭不闹」类太常见,收进来误报换不来收益。
// 校准:《万疆》20 章 0 命中,demo 前 20 章 0 命中。
const NEGATION_PARADE_PATTERNS = [
  /(?:没有[^。!?!?\n,,]{1,12}[,,]){2}/g,
  /没(?:有)?[^。!?!?\n,,]{1,12}[,,]\s*没(?:有)?[^。!?!?\n,,]{1,16}[,,。.][^。!?!?\n,,]{0,6}只(?:是|会|有)/g,
];

// 反序对比腔(实战漏网 C):「是真嗓子,不是修音修出来的」——not-is-comparison 的反序变种。
// 复用 not-is 的排除基建:引号内剥离(maskQuoted)、「是的/是啊」确认语(isAffirmationTagAt);
// 前字排除从 either-or 的 不/就/也 扩展到全部「X是」连词/副词合成词(还是/只是/可是/但是/
// 于是/倒是/像是/若是/要是/正是/便是/总是/老是/更是/最是/算是/怕是/凡是/或是/即是/自是/
// 竟是/原是/本是/仍是/许是/净是/光是/单是/尽是);「是不是」问句起头与「不是吗/不是么/
// 不是吧」反问尾巴单独排除。
// 校准:《万疆》20 章 0 命中,demo 前 20 章 0 命中,按 blocking 实现。
const REVERSE_NOT_IS_PATTERN = /是([^。!?!?\n,,]{1,12})[,,]\s*(?:而)?不是([^。!?!?\n]{1,20})/g;
const REVERSE_NOT_IS_PREV_EXCLUDE = new Set([...COMPACT_EITHER_OR_PREV, '还', '只', '可', '但', '于', '倒', '像', '若', '要', '正', '便', '总', '老', '更', '最', '算', '怕', '凡', '或', '即', '自', '竟', '原', '本', '仍', '许', '净', '光', '单', '尽']);

// 预告式总结收尾(实战漏网 D):「没人知道,这才刚刚开头。」「一场…震惊接力,正朝着…缓缓压了过去。」
// 章尾替读者预告下一章走向是 AI 收尾腔。只扫文末窗口(剥引号后可见字数,按行取整),
// 正文中段的「没人知道」多为普通叙述,不误伤;引号内台词(「没人知道…」)不计。
// 「正式拉开序幕/帷幕」是场内事件的报幕式陈述(真人语料「钟声再度响起,比赛正式拉开序幕」),
// 不是叙述者预告,前置 lookbehind 排除。
// 校准:《万疆》20 章排除「正式拉开序幕」2 处报幕句后 0 命中,demo 前 20 章 0 命中。
const TRAILER_ENDING_PATTERN = /没人知道|谁也不知道|谁也没想到|殊不知|(?:这)?才刚刚开(?:始|头)|正(?:朝着|向着)[^。!?!?\n]{0,24}(?:压|涌|袭|逼)(?:了?过去|了?过来|来)|(?<!正式)拉开(?:序幕|帷幕)|即将(?:开始|来临|降临)/g;
const TRAILER_ENDING_WINDOW_CHARS = 600;

// 引号强调滥用(实战漏网 E,advisory 密度型,风格照 metaphor-density-tic):
// 叙述里短词加引号强调(他是被请来"把关"的)。只数叙述层 1-4 字成对引号片段;
// 排除项:【】系统面板载体、引语动词(说|道|问|喊|答|念|叫|回|吼|嘀咕,加细 骂|写|读|唱)
// 前 6 字/后 3 字邻接的极短台词、引号内含句读的台词、引号外无叙述的行(独立台词/
// 弹幕流/拟声词连发)、引号套引号(台词内强调)。全文 ≥3 处报一条——单处强调是
// 正常修辞,密度高才是模板腔。
// 校准:demo 前 20 章 0 章过阈值;《万疆》20 章 2 章过阈值(海报标语“我在番城”系列、
// “邀战书”等转述载体,真人也这么写),所以该规则只做 advisory,不升 blocking。
const QUOTE_EMPHASIS_MIN_HITS = 3;
const QUOTE_EMPHASIS_MAX_VISIBLE = 4;
const QUOTE_EMPHASIS_SPEECH_VERB_PATTERN = /[说道问喊答念叫回吼骂写读唱嘀咕]/;

const options = {
  json: false,
  files: [],
  failOn: 'all',
};

for (let i = 2; i < process.argv.length; i += 1) {
  const arg = process.argv[i];
  if (arg === '--check') {
    // Accepted for symmetry with normalize-punctuation.js; detection is always check-only.
  } else if (arg === '--json') {
    options.json = true;
  } else if (arg.startsWith('--fail-on=')) {
    const v = arg.slice('--fail-on='.length);
    if (v !== 'blocking' && v !== 'all') die(`--fail-on must be 'blocking' or 'all'`);
    options.failOn = v;
  } else if (arg === '-h' || arg === '--help') {
    process.stdout.write(`${USAGE}\n`);
    process.exit(0);
  } else if (arg.startsWith('-')) {
    die(`Unknown option: ${arg}`);
  } else {
    options.files.push(arg);
  }
}

if (options.files.length === 0) {
  die('No files provided');
}

let failed = false;
const allFindings = [];

for (const file of options.files) {
  const fullPath = path.resolve(file);
  let input;
  try {
    input = fs.readFileSync(fullPath, 'utf8');
  } catch (error) {
    failed = true;
    if (!options.json) console.error(`${file}: unable to read (${error.message})`);
    continue;
  }

  const findings = scanDocument(input).map((finding) => ({ file, ...finding }));
  allFindings.push(...findings);
}

if (options.json) {
  process.stdout.write(`${JSON.stringify({ findings: allFindings }, null, 2)}\n`);
} else {
  for (const finding of allFindings) {
    console.log(`${finding.file}:${finding.line}:${finding.column}: [${finding.severity}] ${finding.type}: ${finding.message} (${finding.excerpt})`);
  }
}

if (failed) process.exit(2);
// --fail-on=blocking 只在出现 blocking finding 时退出 1(advisory 仅报告);默认 all 沿用「有任何 finding 即 1」。
const hasBlocking = allFindings.some((f) => f.severity === 'blocking');
if (options.failOn === 'blocking' ? hasBlocking : allFindings.length > 0) process.exit(1);

function escapeRegExp(text) {
  return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

function escapeRegExpCharClass(text) {
  return text.replace(/[\\\]^-]/g, '\\$&');
}

function die(message) {
  console.error(message);
  console.error(USAGE.trimEnd());
  process.exit(2);
}

function scanDocument(input) {
  const lines = input.split(/\r?\n/);
  const findings = [];
  let fence = null;
  let inFrontMatter = hasYamlFrontMatter(lines);
  let block = [];
  const proseLines = [];

  const flushBlock = () => {
    if (block.length === 0) return;
    findings.push(...scanBlock(block));
    block = [];
  };

  for (let index = 0; index < lines.length; index += 1) {
    const line = lines[index];
    const trimmed = line.trim();

    if (inFrontMatter) {
      if (index > 0 && trimmed === '---') inFrontMatter = false;
      continue;
    }

    const fenceMarker = parseFenceMarker(trimmed);
    if (fence) {
      if (fenceMarker && fenceMarker.char === fence.char && fenceMarker.length >= fence.length) {
        fence = null;
      }
      continue;
    }

    if (fenceMarker) {
      flushBlock();
      fence = fenceMarker;
      continue;
    }

    block.push({ text: line, lineNo: index + 1 });
    proseLines.push({ text: line, lineNo: index + 1 });
  }

  flushBlock();
  findings.push(...scanProsePatterns(proseLines));
  findings.sort((a, b) => a.line - b.line || a.column - b.column);
  return findings;
}

// 段落级检测:碎句号(连续短叙述句)、长段落、破折号(按功能改写,非机械替换)。
function scanProsePatterns(proseLines) {
  const findings = [];

  for (const { text, lineNo } of proseLines) {
    const trimmed = text.trim();
    if (!trimmed || isDivider(trimmed) || isStructural(trimmed)) continue;

    const dashPattern = /——|—|--+/g;
    let dash;
    while ((dash = dashPattern.exec(text)) !== null) {
      findings.push({
        line: lineNo,
        column: dash.index + 1,
        type: 'em-dash',
        severity: 'blocking',
        message: '破折号按功能改写:打断→动作 beat/短句,拖长音→省略或动作,插入说明→逗号/冒号;勿一律改句号。',
        excerpt: compact(text.slice(Math.max(0, dash.index - 8), dash.index + dash[0].length + 8)),
      });
    }

    if (trimmed.length > LONG_PARAGRAPH_CHARS) {
      findings.push({
        line: lineNo,
        column: 1,
        type: 'long-paragraph',
        severity: 'advisory',
        message: `段落过长(${trimmed.length} 字):按镜头/新动作/新线索/视线切换断段,别一段到底。`,
        excerpt: compact(trimmed.slice(0, 40)),
      });
    }
  }

  findings.push(...findVoiceContrast(proseLines));
  findings.push(...findNegationParade(proseLines));
  findings.push(...findReverseNotIs(proseLines));
  findings.push(...findTrailerEnding(proseLines));
  findings.push(...findQuoteEmphasisTic(proseLines));
  findings.push(...findPeriodStutter(proseLines));
  findings.push(...findMicroActionTic(proseLines));
  findings.push(...findActionListTic(proseLines));
  findings.push(...findAbstractSummaryTic(proseLines));
  findings.push(...findClicheDensityTic(proseLines));
  findings.push(...findMetaphorDensityTic(proseLines));
  findings.push(...findReasoningChainTic(proseLines));
  findings.push(...findNoticeFormalityTic(proseLines));
  findings.push(...findOvercompressedProseTic(proseLines));
  findings.push(...findLowConnectiveDensityTic(proseLines));
  return findings;
}

// 音量反差腔(实战漏网 A):引号外叙述逐处 blocking,位置与摘录取自原文
// (maskQuoted 等长占位保偏移;命中片段不含句号,故不会落进占位区)。
function findVoiceContrast(proseLines) {
  const findings = [];

  for (const { text, lineNo } of proseLines) {
    const trimmed = text.trim();
    if (!trimmed || isDivider(trimmed) || isStructural(trimmed)) continue;
    const masked = maskQuoted(text);
    VOICE_CONTRAST_PATTERN.lastIndex = 0;
    let match;
    while ((match = VOICE_CONTRAST_PATTERN.exec(masked)) !== null) {
      findings.push({
        line: lineNo,
        column: match.index + 1,
        type: 'voice-contrast',
        severity: 'blocking',
        message: '音量反差腔:「声音不大/不高…却/但…」是 AI 高频反差模板;删掉音量铺垫,直接写声音落进场子的具体效果(谁停了手、哪排安静了)。',
        excerpt: compact(text.slice(match.index, match.index + match[0].length)),
      });
    }
  }

  return findings;
}

// 否定排比(实战漏网 B):两个变体(同句「没有X,」连排 / 先否定后「只是」收肯定)
// 可能在同一片文字上重叠命中,按区间去重只报一次。
function findNegationParade(proseLines) {
  const findings = [];

  for (const { text, lineNo } of proseLines) {
    const trimmed = text.trim();
    if (!trimmed || isDivider(trimmed) || isStructural(trimmed)) continue;
    const masked = maskQuoted(text);

    const spans = [];
    for (const pattern of NEGATION_PARADE_PATTERNS) {
      pattern.lastIndex = 0;
      let match;
      while ((match = pattern.exec(masked)) !== null) {
        spans.push([match.index, match.index + match[0].length]);
      }
    }
    spans.sort((a, b) => a[0] - b[0]);

    let lastEnd = -1;
    for (const [start, end] of spans) {
      if (start < lastEnd) {
        lastEnd = Math.max(lastEnd, end);
        continue;
      }
      lastEnd = end;
      findings.push({
        line: lineNo,
        column: start + 1,
        type: 'negation-parade',
        severity: 'blocking',
        message: '否定排比:「没有X,没有Y…」/「没X,没有Y,只是Z」是 AI 高频排比模板;删掉否定清单,直接写现场实际有什么,最多留一个最有信息量的否定。',
        excerpt: compact(text.slice(start, end)),
      });
    }
  }

  return findings;
}

// 反序对比腔(实战漏网 C):「是A,不是B」。排除基建复用 not-is-comparison:
// 引号内剥离、「是的/是啊」确认语;前字合成词与反问尾巴见 REVERSE_NOT_IS_PREV_EXCLUDE 注释。
function findReverseNotIs(proseLines) {
  const findings = [];

  for (const { text, lineNo } of proseLines) {
    const trimmed = text.trim();
    if (!trimmed || isDivider(trimmed) || isStructural(trimmed)) continue;
    const masked = maskQuoted(text);
    REVERSE_NOT_IS_PATTERN.lastIndex = 0;
    let match;
    while ((match = REVERSE_NOT_IS_PATTERN.exec(masked)) !== null) {
      const start = match.index;
      // 「就是/也是/还是/只是/可是…」里的「是」是合成词一部分,不是肯定项系动词。
      if (REVERSE_NOT_IS_PREV_EXCLUDE.has(masked[start - 1])) continue;
      // 「是不是…」问句起头。
      if (masked[start + 1] === '不') continue;
      // 「是的,…不是…」承接确认语(复用 not-is 的判定)。
      if (isAffirmationTagAt(masked, start)) continue;
      // 「…,不是吗/不是么/不是吧」反问尾巴。
      if (/^[吗么吧]/.test(match[2])) continue;
      findings.push({
        line: lineNo,
        column: start + 1,
        type: 'reverse-not-is',
        severity: 'blocking',
        message: '反序对比腔:「是A,不是B」与「不是A,是B」同族;删掉后置否定,直接写 A 的具体表现,或用细节让读者自己对比。',
        excerpt: compact(text.slice(start, start + match[0].length)),
      });
    }
  }

  return findings;
}

// 预告式总结收尾(实战漏网 D):只扫文末窗口。从文末往回收集叙述行,
// 直到剥引号后的可见字数达到窗口大小(按行取整,边界行整行计入)。
function findTrailerEnding(proseLines) {
  const windowLines = [];
  let accumulated = 0;

  for (let i = proseLines.length - 1; i >= 0 && accumulated < TRAILER_ENDING_WINDOW_CHARS; i -= 1) {
    const { text } = proseLines[i];
    const trimmed = text.trim();
    if (!trimmed || isDivider(trimmed) || isStructural(trimmed)) continue;
    windowLines.unshift(proseLines[i]);
    accumulated += visibleLength(stripQuoted(trimmed));
  }

  const findings = [];
  for (const { text, lineNo } of windowLines) {
    const masked = maskQuoted(text);
    TRAILER_ENDING_PATTERN.lastIndex = 0;
    let match;
    while ((match = TRAILER_ENDING_PATTERN.exec(masked)) !== null) {
      findings.push({
        line: lineNo,
        column: match.index + 1,
        type: 'trailer-ending',
        severity: 'blocking',
        message: '预告式总结收尾:「没人知道/才刚刚开始/正朝着…压了过去」是 AI 章尾预告腔;结尾停在具体动作、画面或一句台词上,悬念让事件自己挂住,别替读者预告下一章。',
        excerpt: compact(text.slice(match.index, match.index + match[0].length)),
      });
    }
  }

  return findings;
}

// 引号强调滥用(实战漏网 E):统计叙述层 1-4 字成对引号强调片段,全文只报一条
// (密度型分布指纹)。台词类排除见 QUOTE_EMPHASIS_* 常量注释。
function findQuoteEmphasisTic(proseLines) {
  let hits = 0;
  let firstLine = null;
  const samples = [];

  for (const { text, lineNo } of proseLines) {
    const trimmed = text.trim();
    if (!trimmed || isDivider(trimmed) || isStructural(trimmed)) continue;
    // 引号外没有叙述的行(独立台词/弹幕流/拟声词连发「“叮咚~”“叮咚~”」)整行跳过:
    // 强调滥用是叙述层指纹,没有叙述就无所谓强调。
    if (visibleLength(stripQuoted(trimmed)) === 0) continue;
    const ranges = quotedRanges(text);

    for (const [start, end] of ranges) {
      if (text[start] === '【') continue; // 系统面板/公告载体,不是强调引号
      // 引号套引号:台词内部的强调属于角色语言,不算叙述层强调滥用。
      if (ranges.some(([s2, e2]) => s2 <= start && end <= e2 && (s2 !== start || e2 !== end))) continue;
      const inner = text.slice(start + 1, end - 1);
      const visible = visibleLength(inner);
      if (visible < 1 || visible > QUOTE_EMPHASIS_MAX_VISIBLE) continue;
      if (/[。!?!?…,,;;::]/.test(inner)) continue; // 含句读的是台词/播报,不是强调
      const before = text.slice(Math.max(0, start - 6), start);
      const after = text.slice(end, end + 3);
      if (QUOTE_EMPHASIS_SPEECH_VERB_PATTERN.test(before) || QUOTE_EMPHASIS_SPEECH_VERB_PATTERN.test(after)) continue; // 引语动词邻接=极短台词
      hits += 1;
      if (firstLine === null) firstLine = lineNo;
      if (samples.length < 6 && !samples.includes(inner)) samples.push(inner);
    }
  }

  if (hits < QUOTE_EMPHASIS_MIN_HITS) return [];

  return [{
    line: firstLine,
    column: 1,
    type: 'quote-emphasis-tic',
    severity: 'advisory',
    message: `引号强调滥用:叙述里 1-4 字短词加引号强调 ${hits} 处;只留真正反讽/转述必要的一两处,其余去掉引号直接写,或换成具体动作让读者自己品。`,
    excerpt: compact(samples.join(' ')),
  }];
}

// 微动作复读:统计引号外叙述里「了X量词」轻量补语的密度。次数与每千字密度双门槛,
// 全文只报一条(这是分布级指纹,不是逐处问题)。
function findMicroActionTic(proseLines) {
  let hits = 0;
  let narrativeChars = 0;
  let firstLine = null;
  const samples = [];

  for (const { text, lineNo } of proseLines) {
    const trimmed = text.trim();
    if (!trimmed || isDivider(trimmed) || isStructural(trimmed)) continue;
    const narrative = stripQuoted(trimmed);
    narrativeChars += visibleLength(narrative);
    MICRO_TIC_PATTERN.lastIndex = 0;
    let match;
    while ((match = MICRO_TIC_PATTERN.exec(narrative)) !== null) {
      hits += 1;
      if (firstLine === null) firstLine = lineNo;
      if (samples.length < 6 && !samples.includes(match[0])) samples.push(match[0]);
    }
  }

  if (narrativeChars === 0 || hits < MICRO_TIC_MIN_HITS) return [];
  const perKilo = (hits / narrativeChars) * 1000;
  if (perKilo < MICRO_TIC_PER_KILO) return [];

  return [{
    line: firstLine,
    column: 1,
    type: 'micro-action-tic',
    severity: 'advisory',
    message: `微动作复读:「了下/了一下」式轻量补语 ${hits} 处(${perKilo.toFixed(1)}/千字);同一反应模板高密度复现是机械指纹,合并动作 beat、换具体细节,别每个动作都补一个轻反应尾巴。`,
    excerpt: compact(samples.join(' ')),
  }];
}

function findActionListTic(proseLines) {
  const findings = [];

  for (const { text, lineNo } of proseLines) {
    const trimmed = text.trim();
    if (!trimmed || isDivider(trimmed) || isStructural(trimmed)) continue;
    const narrative = stripQuoted(trimmed).trim();
    if (!narrative) continue;

    ACTION_LIST_VERB_PATTERN.lastIndex = 0;
    const verbs = [];
    let match;
    while ((match = ACTION_LIST_VERB_PATTERN.exec(narrative)) !== null) {
      verbs.push(match[0]);
    }

    if (verbs.length < ACTION_LIST_MIN_HITS) continue;
    const separators = (narrative.match(/[,、;;]/g) || []).length;
    if (separators < ACTION_LIST_MIN_SEPARATORS) continue;

    findings.push({
      line: lineNo,
      column: 1,
      type: 'action-list-tic',
      severity: 'advisory',
      message: `监控摄像头式动作清单:同段连续动作动词 ${verbs.length} 个、分隔符 ${separators} 个;合并琐碎步骤,只保留有情绪/情节功能的动作,必要时用角色犹豫、误判或环境反馈做缓冲。`,
      excerpt: compact(verbs.slice(0, 8).join(' ')),
    });
  }

  return findings;
}

// 套词密度:统计引号外叙述中的高危禁用词聚集。不是逐词替换器;只在密度高到
// 形成模板腔时提示,修法是删总结、换具体动作/物件/对话,不是同义词轮换。
function findClicheDensityTic(proseLines) {
  let hits = 0;
  let narrativeChars = 0;
  let firstLine = null;
  const samples = [];

  for (const { text, lineNo } of proseLines) {
    const trimmed = text.trim();
    if (!trimmed || isDivider(trimmed) || isStructural(trimmed)) continue;
    const narrative = stripQuoted(trimmed);
    narrativeChars += visibleLength(narrative);

    for (const pattern of CLICHE_PATTERNS) {
      pattern.lastIndex = 0;
      let match;
      while ((match = pattern.exec(narrative)) !== null) {
        hits += 1;
        if (firstLine === null) firstLine = lineNo;
        if (samples.length < 8 && !samples.includes(match[0])) samples.push(match[0]);
      }
    }
  }

  if (narrativeChars === 0 || hits < CLICHE_DENSITY_MIN_HITS) return [];
  const perKilo = (hits / narrativeChars) * 1000;
  if (perKilo < CLICHE_DENSITY_PER_KILO) return [];

  return [{
    line: firstLine,
    column: 1,
    type: 'cliche-density-tic',
    severity: 'advisory',
    message: `套词密度过高:高危 AI 套词 ${hits} 处(${perKilo.toFixed(1)}/千字);不要同义词轮换,改成角色当下可见的动作、物件、对话和具体后果。`,
    excerpt: compact(samples.join(' ')),
  }];
}

// 比喻密度:统计引号外叙述中“像/好像/仿佛/如同”等比喻标记。
// 单个比喻不是问题;高密度成片时才提示,避免把文本改成另一种修辞模板。
function findMetaphorDensityTic(proseLines) {
  let hits = 0;
  let narrativeChars = 0;
  let firstLine = null;
  const samples = [];

  for (const { text, lineNo } of proseLines) {
    const trimmed = text.trim();
    if (!trimmed || isDivider(trimmed) || isStructural(trimmed)) continue;
    const narrative = stripQuoted(trimmed);
    narrativeChars += visibleLength(narrative);

    METAPHOR_MARKER_PATTERN.lastIndex = 0;
    let match;
    while ((match = METAPHOR_MARKER_PATTERN.exec(narrative)) !== null) {
      hits += 1;
      if (firstLine === null) firstLine = lineNo;
      const sample = sentenceAround(narrative, match.index);
      if (samples.length < 6 && sample && !samples.includes(sample)) samples.push(sample);
    }

    METAPHOR_LIKE_PHRASE_PATTERN.lastIndex = 0;
    while ((match = METAPHOR_LIKE_PHRASE_PATTERN.exec(narrative)) !== null) {
      const prefix = narrative.slice(Math.max(0, match.index - 8), match.index);
      if (/好像|像是|像|仿佛|宛如|如同|犹如/.test(prefix)) continue;
      hits += 1;
      if (firstLine === null) firstLine = lineNo;
      const sample = sentenceAround(narrative, match.index);
      if (samples.length < 6 && sample && !samples.includes(sample)) samples.push(sample);
    }
  }

  if (narrativeChars === 0 || hits < METAPHOR_DENSITY_MIN_HITS) return [];
  const perKilo = (hits / narrativeChars) * 1000;
  if (perKilo < METAPHOR_DENSITY_PER_KILO) return [];

  return [{
    line: firstLine,
    column: 1,
    type: 'metaphor-density-tic',
    severity: 'advisory',
    message: `比喻密度过高:像/好像/仿佛/如同等比喻标记 ${hits} 处(${perKilo.toFixed(1)}/千字);保留最有叙事功能的少数比喻,其余回到具体动作、物件、声音或后果,不要换成新比喻。`,
    excerpt: compact(samples.join(' | ')),
  }];
}

// 解释链密度:统计引号外叙述中“知道/明白/这意味着/必须需要”等判断链。
// 全篇只报一条;修法不是补结构虚词,而是把判断落到动作、物件、对话和现场反馈。
function findReasoningChainTic(proseLines) {
  let hits = 0;
  let coreHits = 0;
  let narrativeChars = 0;
  let firstLine = null;
  const samples = [];
  const buckets = new Set();

  for (const { text, lineNo } of proseLines) {
    const trimmed = text.trim();
    if (!trimmed || isDivider(trimmed) || isStructural(trimmed)) continue;
    const narrative = stripQuoted(trimmed);
    narrativeChars += visibleLength(narrative);

    for (const { pattern, key, core } of REASONING_CHAIN_PATTERNS) {
      pattern.lastIndex = 0;
      let match;
      while ((match = pattern.exec(narrative)) !== null) {
        hits += 1;
        if (core) coreHits += 1;
        buckets.add(key);
        if (firstLine === null) firstLine = lineNo;
        const sample = compact(match[0]);
        if (samples.length < 8 && !samples.includes(sample)) samples.push(sample);
      }
    }
  }

  if (narrativeChars === 0 || hits < REASONING_CHAIN_MIN_HITS) return [];
  if (coreHits < REASONING_CHAIN_CORE_MIN_HITS || buckets.size < REASONING_CHAIN_MIN_BUCKETS) return [];
  const perKilo = (hits / narrativeChars) * 1000;
  if (perKilo < REASONING_CHAIN_PER_KILO) return [];

  return [{
    line: firstLine,
    column: 1,
    type: 'reasoning-chain-tic',
    severity: 'advisory',
    message: `解释链密度过高:知道/明白/这意味着/必须/需要等判断链 ${hits} 处(${perKilo.toFixed(1)}/千字);像逻辑报告时,把判断落到角色当下可见的动作、物件、对话和现场反馈。`,
    excerpt: compact(samples.join(' | ')),
  }];
}

// 系统/规则行如果连续像 API 文档或政府公文,读者容易闻到机器味。
// 修法不是删除规则,而是保留功能后把一部分硬词改成白话或具体后果。
function findNoticeFormalityTic(proseLines) {
  let hits = 0;
  let noticeChars = 0;
  let noticeLines = 0;
  let coreHits = 0;
  let firstLine = null;
  const samples = [];

  for (const { text, lineNo } of proseLines) {
    const trimmed = text.trim();
    if (!/^【[^】]+】$/.test(trimmed)) continue;
    noticeLines += 1;
    noticeChars += visibleLength(trimmed);

    NOTICE_FORMAL_CORE_PATTERN.lastIndex = 0;
    while (NOTICE_FORMAL_CORE_PATTERN.exec(trimmed) !== null) coreHits += 1;

    for (const pattern of NOTICE_FORMAL_PATTERNS) {
      pattern.lastIndex = 0;
      let match;
      while ((match = pattern.exec(trimmed)) !== null) {
        hits += 1;
        if (firstLine === null) firstLine = lineNo;
        const sample = compact(match[0]);
        if (samples.length < 8 && !samples.includes(sample)) samples.push(sample);
      }
    }
  }

  if (noticeLines < NOTICE_FORMAL_MIN_LINES || noticeChars === 0 || hits < NOTICE_FORMAL_MIN_HITS || coreHits < NOTICE_FORMAL_CORE_MIN_HITS) return [];
  const perKilo = (hits / noticeChars) * 1000;
  if (perKilo < NOTICE_FORMAL_PER_KILO) return [];

  return [{
    line: firstLine,
    column: 1,
    type: 'system-notice-formality-tic',
    severity: 'advisory',
    message: `系统公告公文腔过密:方括号规则行中硬规则词 ${hits} 处(${perKilo.toFixed(1)}/千字);保留为角色看见的屏幕/公告/规则载体,只在载体内部白话化部分硬词,或补角色当场看懂的具体后果,不改成叙述者解释。`,
    excerpt: compact(samples.join(' | ')),
  }];
}

// 长文本整体过于“精炼”:短段很多、自然连接偏少,读起来像处理过的梗概/分镜表。
// 修法是通读后补断裂处,不是为凑阈值全局加“的/了/就”。
function findOvercompressedProseTic(proseLines) {
  let narrativeChars = 0;
  let narrativeParas = 0;
  let shortParas = 0;
  let particles = 0;
  let firstLine = null;
  const samples = [];

  for (const { text, lineNo } of proseLines) {
    const trimmed = text.trim();
    if (!trimmed || isDivider(trimmed) || isStructural(trimmed) || /^【[^】]+】$/.test(trimmed)) continue;
    const narrative = stripQuoted(trimmed).trim();
    const len = visibleLength(narrative);
    if (len === 0) continue;

    if (firstLine === null) firstLine = lineNo;
    narrativeParas += 1;
    narrativeChars += len;
    if (len <= OVERCOMPRESSED_PROSE_SHORT_MAX_CHARS) {
      shortParas += 1;
      if (samples.length < 6) samples.push(narrative);
    }

    OVERCOMPRESSED_PROSE_PARTICLE_PATTERN.lastIndex = 0;
    while (OVERCOMPRESSED_PROSE_PARTICLE_PATTERN.exec(narrative) !== null) particles += 1;
  }

  if (narrativeChars < OVERCOMPRESSED_PROSE_MIN_CHARS || narrativeParas < OVERCOMPRESSED_PROSE_MIN_PARAS) return [];
  const shortRatio = shortParas / narrativeParas;
  if (shortRatio < OVERCOMPRESSED_PROSE_SHORT_RATIO) return [];
  const particlePerKilo = (particles / narrativeChars) * 1000;
  if (particlePerKilo >= OVERCOMPRESSED_PROSE_PARTICLE_PER_KILO) return [];

  return [{
    line: firstLine,
    column: 1,
    type: 'overcompressed-prose-tic',
    severity: 'advisory',
    message: `过度精炼短段:叙述段 ${narrativeParas} 个,其中 ${shortParas} 个≤${OVERCOMPRESSED_PROSE_SHORT_MAX_CHARS}字(${(shortRatio * 100).toFixed(0)}%),自然连接 ${particlePerKilo.toFixed(1)}/千字偏少;先通读判断,确有提纲感再补断裂处和必要结构虚词,有意短镜头可留,别机械注水。`,
    excerpt: compact(samples.join(' | ')),
  }];

}

// 低连接密度:长文本/中短窗口里,引号外叙述的功能词和白话连接同时偏低,且缺少中长承接句,
// 会呈现“提纲/电报体”分布。修法是恢复必要连接和句群,不是全局补词。
function findLowConnectiveDensityTic(proseLines) {
  let bodyChars = 0;
  let functionHits = 0;
  let plainHits = 0;
  let firstLine = null;
  const sentences = [];
  const samples = [];

  for (const { text, lineNo } of proseLines) {
    const trimmed = text.trim();
    if (!trimmed || isDivider(trimmed) || isStructural(trimmed)) continue;

    // 只看引号外叙述。台词/弹幕/系统播报可以天然短促,混入统计会把体裁特征误当电报体。
    const narrative = stripQuoted(trimmed).trim();
    const narrativeLen = visibleLength(narrative);
    if (narrativeLen === 0) continue;

    if (firstLine === null) firstLine = lineNo;
    bodyChars += narrativeLen;
    functionHits += countTerms(narrative, LOW_CONNECTIVE_FUNCTION_TERMS);
    plainHits += countTerms(narrative, LOW_CONNECTIVE_PLAIN_TERMS);

    for (const sentence of splitSentences(narrative)) {
      const len = visibleLength(sentence);
      if (len === 0) continue;
      sentences.push(len);
      if (len <= 12 && samples.length < 6) samples.push(sentence);
    }
  }

  if (bodyChars < LOW_CONNECTIVE_MIN_CHARS || sentences.length === 0) return [];
  const functionPerKilo = (functionHits / bodyChars) * 1000;
  if (functionPerKilo >= LOW_CONNECTIVE_FUNCTION_PER_KILO) return [];
  const plainPerKilo = (plainHits / bodyChars) * 1000;
  if (plainPerKilo >= LOW_CONNECTIVE_PLAIN_PER_KILO) return [];
  const longSentenceRatio = sentences.filter((len) => len >= LOW_CONNECTIVE_LONG_SENTENCE_CHARS).length / sentences.length;
  if (longSentenceRatio >= LOW_CONNECTIVE_LONG_SENTENCE_RATIO) return [];

  return [{
    line: firstLine,
    column: 1,
    type: 'low-connective-density-tic',
    severity: 'advisory',
    message: `低连接密度:引号外叙述功能词 ${functionPerKilo.toFixed(1)}/千字、白话连接 ${plainPerKilo.toFixed(1)}/千字,且≥${LOW_CONNECTIVE_LONG_SENTENCE_CHARS}字承接句仅 ${(longSentenceRatio * 100).toFixed(0)}%;容易像提纲/电报体。通读后补必要连接和中长句群,别机械注水。`,
    excerpt: compact(samples.join(' | ')),
  }];
}

// 抽象总结复读:统计引号外叙述中的高抽象收束模板。全篇只报一条,提醒回到角色
// 当下可见的文件、动作、对话或物理后果;不要用命运大词替读者总结。
function findAbstractSummaryTic(proseLines) {
  let hits = 0;
  let narrativeChars = 0;
  let firstLine = null;
  const samples = [];

  for (const { text, lineNo } of proseLines) {
    const trimmed = text.trim();
    if (!trimmed || isDivider(trimmed) || isStructural(trimmed)) continue;
    const narrative = stripQuoted(trimmed);
    narrativeChars += visibleLength(narrative);

    for (const pattern of ABSTRACT_SUMMARY_PATTERNS) {
      pattern.lastIndex = 0;
      let match;
      while ((match = pattern.exec(narrative)) !== null) {
        hits += 1;
        if (firstLine === null) firstLine = lineNo;
        const sample = compact(match[0]);
        if (samples.length < 6 && !samples.includes(sample)) samples.push(sample);
      }
    }
  }

  if (narrativeChars === 0 || hits < ABSTRACT_SUMMARY_MIN_HITS) return [];
  const perKilo = (hits / narrativeChars) * 1000;
  if (perKilo < ABSTRACT_SUMMARY_PER_KILO) return [];

  return [{
    line: firstLine,
    column: 1,
    type: 'abstract-summary-tic',
    severity: 'advisory',
    message: `抽象总结复读:命运/棋局/这一刻终于明白/才刚刚开始等作者总结 ${hits} 处(${perKilo.toFixed(1)}/千字);回到角色当下可见的文件、动作、对话或物理后果,别替读者盖章。`,
    excerpt: compact(samples.join(' | ')),
  }];
}

function findPeriodStutter(proseLines) {
  const findings = [];
  let runLen = 0;
  let runStartLine = null;
  let runSample = [];

  const flush = () => {
    if (runLen >= STUTTER_MIN_RUN) {
      findings.push({
        line: runStartLine,
        column: 1,
        type: 'period-stutter',
        severity: 'advisory',
        message: `碎句号:连续 ${runLen} 个短句无呼吸;按目标句长把碎句合并成中长句、补回画面与连接(见本 skill 句长/疏密节奏规则)。`,
        excerpt: compact(runSample.join(' ')),
      });
    }
    runLen = 0;
    runStartLine = null;
    runSample = [];
  };

  for (const { text, lineNo } of proseLines) {
    const trimmed = text.trim();
    if (!trimmed) continue; // 空行是一句一段排版,不打断叙述连贯
    if (isDivider(trimmed) || isStructural(trimmed)) {
      flush(); // 分隔线/markdown 结构行:重置碎句计数
      continue;
    }
    const narrative = stripQuoted(trimmed);
    if (visibleLength(narrative) === 0) {
      flush(); // 纯对话/弹幕/系统播报:成片短句是正常形态,重置碎句计数
      continue;
    }
    // 只数引号外叙述句:混合行(叙述+引号内物件/短台词)的引号外片段仍参与碎句计数。
    for (const sentence of splitSentences(narrative)) {
      if (visibleLength(sentence) <= STUTTER_MAX_SENTENCE) {
        if (runLen === 0) runStartLine = lineNo;
        runLen += 1;
        if (runSample.length < 6) runSample.push(sentence);
      } else {
        flush();
      }
    }
  }
  flush();
  return findings;
}

function isDivider(trimmed) {
  return /^-{3,}$/.test(trimmed) || /^[*_]{3,}$/.test(trimmed);
}

// markdown 结构行(标题/列表/引用/表格)不是叙述正文,长段落/碎句号/破折号检测都跳过。
function isStructural(trimmed) {
  return /^(#{1,6}\s|>\s?|[-*+]\s|\d+[.)]\s|\|)/.test(trimmed)
    || /^第[零一二三四五六七八九十百千万\d]+章(?:\s|_|$)/.test(trimmed);
}

// 去掉成对引号内的片段(台词/系统播报),只留引号外叙述。碎句号判定用:纯对话/弹幕成片短句
// 是体裁正常形态(豁免),但「叙述 + 引号内物件/短台词」混合行的引号外叙述仍要参与短句计数。
function stripQuoted(text) {
  let out = text;
  for (const src of QUOTE_SOURCES) out = out.replace(new RegExp(src, 'g'), '');
  return out;
}

// 把成对引号片段(含引号)替换为等长句号占位:既豁免引号内台词/播报,又保住原文
// 偏移量,供逐处 blocking 规则定位与截取原文摘录(stripQuoted 会移位,不适合定位)。
// 句号占位天然截断各规则的 [^。…] 字符类,规则不会跨引号拼出假命中。
function maskQuoted(text) {
  let out = text;
  for (const src of QUOTE_SOURCES) {
    out = out.replace(new RegExp(src, 'g'), (m) => '。'.repeat(m.length));
  }
  return out;
}

// 返回引号内片段(含引号本身)的 [start, end) 区间,供 not-is 对比句豁免台词用。
function quotedRanges(text) {
  const ranges = [];
  for (const src of QUOTE_SOURCES) {
    const re = new RegExp(src, 'g');
    let match;
    while ((match = re.exec(text)) !== null) ranges.push([match.index, match.index + match[0].length]);
  }
  return ranges;
}

function insideRanges(pos, ranges) {
  return ranges.some(([start, end]) => pos >= start && pos < end);
}

function splitSentences(trimmed) {
  return trimmed
    .split(/[。!?!?]/)
    .map((s) => s.trim())
    .filter(Boolean);
}

function sentenceAround(text, index) {
  let start = index;
  while (start > 0 && !STOP_CHARS.has(text[start - 1])) start -= 1;
  let end = index;
  while (end < text.length && !STOP_CHARS.has(text[end])) end += 1;
  return compact(text.slice(start, end).trim());
}

function visibleLength(sentence) {
  const matched = sentence.match(/[一-鿿A-zA-Za-z0-9]/g);
  return matched ? matched.length : 0;
}

function countTerms(text, terms) {
  let count = 0;
  for (const term of terms) {
    let index = text.indexOf(term);
    while (index !== -1) {
      count += 1;
      index = text.indexOf(term, index + term.length);
    }
  }
  return count;
}

function parseFenceMarker(trimmedLine) {
  const match = /^(?:`{3,}|~{3,})/.exec(trimmedLine);
  if (!match) return null;
  return { char: match[0][0], length: match[0].length };
}

function hasYamlFrontMatter(lines) {
  if (!lines[0] || lines[0].trim() !== '---') return false;
  let sawYamlField = false;
  for (let i = 1; i < Math.min(lines.length, 40); i += 1) {
    const trimmed = lines[i].trim();
    if (trimmed === '---') return sawYamlField;
    if (/^[A-Za-z0-9_-]+:\s*/.test(trimmed)) sawYamlField = true;
  }
  return false;
}

function scanBlock(block) {
  const text = block.map((entry) => entry.text).join('\n');
  const lineStarts = [];
  let cursor = 0;

  for (const entry of block) {
    lineStarts.push({ offset: cursor, lineNo: entry.lineNo });
    cursor += entry.text.length + 1;
  }

  return findNotIsComparisons(text, (offset) => positionForOffset(lineStarts, offset));
}

function positionForOffset(lineStarts, offset) {
  let low = 0;
  let high = lineStarts.length - 1;

  while (low <= high) {
    const mid = Math.floor((low + high) / 2);
    const current = lineStarts[mid];
    const next = lineStarts[mid + 1];

    if (offset < current.offset) {
      high = mid - 1;
    } else if (next && offset >= next.offset) {
      low = mid + 1;
    } else {
      return {
        line: current.lineNo,
        column: offset - current.offset + 1,
      };
    }
  }

  return { line: lineStarts[0].lineNo, column: 1 };
}

function findNotIsComparisons(text, getPosition) {
  const findings = [];
  const quoted = quotedRanges(text);
  let offset = 0;

  while (offset < text.length) {
    const start = text.indexOf('不是', offset);
    if (start === -1) break;

    // 引号内是台词/系统播报:口语里「不是A,是B」是自然辩解/反问,不算叙述层 AI 对比句式
    // (与碎句号一致豁免引号内容)。
    if (insideRanges(start, quoted)) {
      offset = start + 2;
      continue;
    }

    // Avoid the common yes/no question fragment “是不是”.
    if (start > 0 && text[start - 1] === '是') {
      offset = start + 2;
      continue;
    }

    const candidate = text.slice(start);
    const markerEnd = findPositiveFlipEnd(candidate);

    if (markerEnd === -1) {
      offset = start + 2;
      continue;
    }

    const raw = trimTrailingNoise(extractFinding(candidate, markerEnd));
    if (raw.length >= 4) {
      const position = getPosition(start);
      findings.push({
        line: position.line,
        column: position.column,
        type: 'not-is-comparison',
        severity: 'blocking',
        message: '高频 AI 对比句式;删掉否定铺垫,直接写后项,或改成动作/细节呈现。',
        excerpt: compact(raw),
      });
    }

    offset = start + Math.max(raw.length, 2);
  }

  return findings;
}

function findPositiveFlipEnd(candidate) {
  let index = 2; // after “不是”
  let scanned = 0;
  let crossedSeparator = false;

  while (index < candidate.length && scanned <= MAX_NEGATIVE_SPAN) {
    const char = candidate[index];

    if (startsWithAt(candidate, index, '而是')) return index + 2;

    if (SOFT_SEPARATORS.has(char)) {
      const next = skipGap(candidate, index + 1);
      if (startsWithAt(candidate, next, '而是')) return next + 2;
      if (candidate[next] === '是' && !TAG_PARTICLES.has(candidate[next + 1]) && !isAffirmationTagAt(candidate, next)) return next + 1;
      crossedSeparator = true;
    }

    if (HARD_SEPARATORS.has(char)) {
      const next = skipGap(candidate, index + 1);
      if (candidate[next] === '是' && !TAG_PARTICLES.has(candidate[next + 1]) && !isAffirmationTagAt(candidate, next)) return next + 1;
      if (char !== '.') break;
      crossedSeparator = true;
    }

    if (STOP_CHARS.has(char)) break;

    // Catch compact forms such as “不是A是B”, but only within the first clause —
    // before any separator. After a separator the trailing “是” of a conjunction
    // (只是/可是/但是/还是/于是/倒是/总是…) is part of that word, not a positive
    // copula (issue #166 false-positive class). Post-separator flips are still
    // caught when separator-adjacent (“,是”/“,而是”) by the separator branches
    // above; subject-present flips like “,他是”/“,那是” are intentionally NOT
    // caught here — there is no separator-local way to tell them from a
    // conjunction without a word list, and on a hard rescan-to-0 gate a false
    // positive (forcing a rewrite of good prose) costs more than missing this
    // rarer form. The “是” in the either-or idiom “不是A就是B / 也是B” is part of
    // the 就是/也是 conjunction, not a copula, so 就/也 are excluded too. Also never
    // treat the “是” inside a second negative fragment (“不是A,也不是B”) as the flip.
    if (char === '是' && !COMPACT_EITHER_OR_PREV.has(candidate[index - 1]) && !crossedSeparator) {
      return index + 1;
    }

    index += 1;
    scanned += 1;
  }

  return -1;
}

function extractFinding(candidate, markerEnd) {
  let end = markerEnd;
  const limit = Math.min(candidate.length, markerEnd + MAX_POSITIVE_SPAN);

  while (end < limit) {
    if (STOP_CHARS.has(candidate[end])) break;
    end += 1;
  }

  return candidate.slice(0, end);
}

function startsWithAt(text, index, needle) {
  return text.slice(index, index + needle.length) === needle;
}

function isAffirmationTagAt(text, index) {
  if (text[index] !== '是') return false;
  const particle = text[index + 1];
  if (!AFFIRMATION_TAG_PARTICLES.has(particle)) return false;
  const boundary = text[index + 2] || '';
  return AFFIRMATION_TAG_BOUNDARY.has(boundary);
}

// 跳过行内空白与换行(含空行/段落间距),停在下一个实义字符。原实现只吞一个换行,
// 会漏掉跨空行的「不是A。(空行)是B」这类分段揭示句。
function skipGap(text, index) {
  while (index < text.length && (isInlineSpace(text[index]) || text[index] === '\n')) index += 1;
  return index;
}

function isInlineSpace(char) {
  return char === ' ' || char === '\t' || char === '\r';
}

function trimTrailingNoise(text) {
  return text.replace(/[\s|))】\]]+$/u, '');
}

function compact(text) {
  const normalized = text.replace(/\s+/g, ' ').trim();
  return normalized.length > 80 ? `${normalized.slice(0, 77)}...` : normalized;
}
scripts/check-degeneration.js
#!/usr/bin/env node
'use strict';

const fs = require('fs');
const path = require('path');

const USAGE = `Usage: node check-degeneration.js [--check] [--json] [--fail-on=blocking|all] <file...>

Detect model-degeneration fingerprints that a degrading model cannot self-report:
  - verbatim repetition (复读/打转): a long sentence repeated, or back-to-back identical lines
  - mid-sentence truncation (截断): file ends without terminal/closing punctuation
  - placeholder / refusal / meta leakage (元信息泄漏): 作为AI / 我无法继续 / 此处省略 / 乱码
  - engineering-word leakage (工程词泄漏): 细纲 / 情节点 / 本章 / 下一章 / 任务描述 漏进正文

Each finding carries severity: blocking (复读/截断/占位拒绝语/tier1 纯工程词,正文里永不合法,
命中即重写) 或 advisory (tier2 章节/歧义词、对话行里的工程词,只提示、交人/LLM 判)。
--fail-on=blocking 只在出现 blocking finding 时退出 1;默认 --fail-on=all 有任何 finding 即退出 1。

Report-only. The script never rewrites — the safe response is to regenerate the
affected unit (chapter / 摘要) with the finding fed back as a constraint, cap retries,
then surface the evidence to the user. Conservative by design: 通俗网文 deliberately
uses 排比/复沓/弹幕刷屏/重复台词 for rhythm, so short and dialogue repetition is exempt.`;

// 复读:长句(可见字数 ≥ REPEAT_MIN_LEN)出现 ≥ REPEAT_MIN_COUNT 次判为打转;
// 紧邻整行重复(可见字数 ≥ ADJACENT_MIN_LEN)判为即时循环。短句/弹幕/对话刷屏豁免。
const REPEAT_MIN_LEN = 12;
const REPEAT_MIN_COUNT = 3;
const ADJACENT_MIN_LEN = 8;

// hard = 任何行都判(正文里永不合法);soft = 只在「非对话」叙述行判(角色台词里可能合法,
// 如「对不起,我无法答应你」是正常对话,不是模型拒绝语)。
const PLACEHOLDER_PATTERNS = [
  // 「作为AI」需在自指位置(其后是断句/我/无法… 或句末),避免误报「人工智能时代的产物」这类
  // 复合名词;并对对话行豁免(系统流/AI 伴侣题材里 AI 角色台词「作为AI,我会保护你」是合法对话)。
  { re: /作为(一个)?(AI|人工智能|大?语言模型|智能助手|聊天助手)(?=[,,。、;;::!!??\s))」』"】]|我|无法|不能|没法|$)/, label: '元信息泄漏(AI 自指)', hard: false },
  { re: /�/, label: '乱码(替换字符 �)', hard: true },
  { re: /^(Sure|Certainly|Here'?s|As an AI|I (?:cannot|can't|am unable|apologize))/, label: '元信息泄漏(英文 AI 腔)', hard: true },
  { re: /[((](此处|以下|这里|下文|后续)?\s*(省略|略)(去|过)?[^))]{0,10}[))]/, label: '占位符(括号省略)', hard: true },
  { re: /(未完待续|TODO|占位符|placeholder)/, label: '占位符', hard: true },
  { re: /我(无法|不能)(继续(写|创作|生成|下去)|生成(内容|文本|正文)?|创作|续写|完成(这个|本)?(章|篇|创作|请求))/, label: '元信息泄漏(生成拒绝语)', hard: false },
];

// 工程词泄漏(正文元信息扫描的确定性版):弱模型把写作工程词漏进正文,破坏代入感
// (DeepSeek-v4 这类会在对话里冒「该到下一章了」)。漏词的模型自己发现不了,靠脚本兜。
// tier1 = 纯写作流水线术语,正文里几乎永不合法;tier2 = 章节结构/歧义词,角色在故事内
// 真实阅读/讨论「第X章」或故事内系统/界面用语时属例外(report-only,交人/LLM 判)。
const META_TIER1_RE = /细纲|情节点|卷纲|功能标签|目标情绪|字数目标|章首钩子|章尾钩子/;
const META_TIER2_RE = /第[一二三四五六七八九十百千万两0-9]+章|本章|这一章|上一章|下一章|上章|下章|前一章|后一章|前文|后文|伏笔|读者|任务描述/;

const options = { json: false, files: [], failOn: 'all' };

for (let i = 2; i < process.argv.length; i += 1) {
  const arg = process.argv[i];
  if (arg === '--check') {
    // Accepted for symmetry with the other detectors; detection is always check-only.
  } else if (arg === '--json') {
    options.json = true;
  } else if (arg.startsWith('--fail-on=')) {
    const v = arg.slice('--fail-on='.length);
    if (v !== 'blocking' && v !== 'all') die(`--fail-on must be 'blocking' or 'all'`);
    options.failOn = v;
  } else if (arg === '-h' || arg === '--help') {
    process.stdout.write(`${USAGE}\n`);
    process.exit(0);
  } else if (arg.startsWith('-')) {
    die(`Unknown option: ${arg}`);
  } else {
    options.files.push(arg);
  }
}

if (options.files.length === 0) {
  die('No files provided');
}

let failed = false;
const allFindings = [];

for (const file of options.files) {
  const fullPath = path.resolve(file);
  let input;
  try {
    input = fs.readFileSync(fullPath, 'utf8');
  } catch (error) {
    failed = true;
    if (!options.json) console.error(`${file}: unable to read (${error.message})`);
    continue;
  }
  const findings = scanDocument(input).map((finding) => ({ file, ...finding }));
  allFindings.push(...findings);
}

if (options.json) {
  process.stdout.write(`${JSON.stringify({ findings: allFindings }, null, 2)}\n`);
} else {
  for (const f of allFindings) {
    console.log(`${f.file}:${f.line}:${f.column}: [${f.severity}] ${f.type}: ${f.message} (${f.excerpt})`);
  }
}

if (failed) process.exit(2);
// --fail-on=blocking 只在出现 blocking finding 时退出 1(advisory 仅报告);默认 all 沿用「有任何 finding 即 1」。
const hasBlocking = allFindings.some((f) => f.severity === 'blocking');
if (options.failOn === 'blocking' ? hasBlocking : allFindings.length > 0) process.exit(1);

function die(message) {
  console.error(message);
  console.error(USAGE.trimEnd());
  process.exit(2);
}

function scanDocument(input) {
  const lines = input.split(/\r?\n/);
  const content = []; // { text, trimmed, lineNo } for body lines outside front-matter/fences
  let fence = null;
  let inFrontMatter = hasYamlFrontMatter(lines);

  for (let index = 0; index < lines.length; index += 1) {
    const line = lines[index];
    const trimmed = line.trim();
    if (inFrontMatter) {
      if (index > 0 && trimmed === '---') inFrontMatter = false;
      continue;
    }
    const fenceMarker = /^(?:`{3,}|~{3,})/.exec(trimmed);
    if (fence) {
      if (fenceMarker && trimmed[0] === fence) fence = null;
      continue;
    }
    if (fenceMarker) {
      fence = trimmed[0];
      continue;
    }
    content.push({ text: line, trimmed, lineNo: index + 1 });
  }

  const findings = [];
  findings.push(...findRepetition(content));
  findings.push(...findTruncation(content));
  findings.push(...findPlaceholders(content));
  findings.push(...findMetaLeak(content));
  findings.sort((a, b) => a.line - b.line || a.column - b.column);
  return findings;
}

function isContent(trimmed) {
  return trimmed && !trimmed.startsWith('#') && !/^-{3,}$/.test(trimmed);
}

function isDialogueLike(trimmed) {
  return /[“”"'‘’「」『』【】]/.test(trimmed);
}

// 去掉成对引号内的片段(台词/系统词/引用物件),只留引号外叙述。复读判定用:重复台词是体裁
// 手法(豁免),但「叙述 + 引号内物件/短台词」混合行里引号外叙述的复读仍是退化,不能整行豁免。
function stripQuoted(text) {
  return text
    .replace(/「[^」]*」/g, '')
    .replace(/『[^』]*』/g, '')
    .replace(/【[^】]*】/g, '')
    .replace(/“[^”]*”/g, '')
    .replace(/‘[^’]*’/g, '')
    .replace(/"[^"]*"/g, '')
    .replace(/'[^']*'/g, '');
}

function visibleLength(text) {
  const m = text.match(/[一-鿿A-zA-Za-z0-9]/g);
  return m ? m.length : 0;
}

function findRepetition(content) {
  const findings = [];
  const body = content.filter((c) => isContent(c.trimmed));

  // (1) back-to-back identical lines (immediate loop). 纯台词/弹幕复沓(引号外叙述很短)豁免;
  // 「叙述 + 引号内物件」混合行的整行复读仍判(去引号后叙述够长)。
  for (let i = 1; i < body.length; i += 1) {
    if (
      body[i].trimmed === body[i - 1].trimmed &&
      visibleLength(stripQuoted(body[i].trimmed)) >= ADJACENT_MIN_LEN
    ) {
      findings.push({
        line: body[i].lineNo,
        column: 1,
        type: 'verbatim-repeat',
        severity: 'blocking',
        message: '逐行复读(紧邻整行重复):疑似模型打转,重写本段、删掉重复。',
        excerpt: compact(body[i].trimmed),
      });
    }
  }

  // (2) any long sentence repeated >= REPEAT_MIN_COUNT times across the file.
  // 只豁免引号内台词(体裁手法),引号外叙述句仍参与复读计数(含「叙述+引号内物件」混合行)。
  const counts = new Map();
  for (const { trimmed } of body) {
    for (const sentence of stripQuoted(trimmed).split(/[。!?!?]/)) {
      const s = sentence.trim();
      if (visibleLength(s) < REPEAT_MIN_LEN) continue;
      const entry = counts.get(s) || { count: 0, firstLine: null };
      entry.count += 1;
      counts.set(s, entry);
    }
  }
  // record first line for repeated sentences
  const flagged = new Set();
  for (const [s, entry] of counts) {
    if (entry.count >= REPEAT_MIN_COUNT) flagged.add(s);
  }
  if (flagged.size) {
    for (const { trimmed, lineNo } of body) {
      for (const sentence of stripQuoted(trimmed).split(/[。!?!?]/)) {
        const s = sentence.trim();
        if (flagged.has(s)) {
          findings.push({
            line: lineNo,
            column: 1,
            type: 'verbatim-repeat',
            severity: 'blocking',
            message: `长句复读(同句出现 ${counts.get(s).count} 次):疑似模型打转,重写、保留一处。`,
            excerpt: compact(s),
          });
          flagged.delete(s); // report each repeated sentence once, at its first occurrence
        }
      }
    }
  }

  return findings;
}

function findTruncation(content) {
  const body = content.filter((c) => isContent(c.trimmed));
  if (body.length === 0) return [];
  const last = body[body.length - 1];
  // a finished chapter ends on terminal/closing punctuation; otherwise it was cut off.
  if (/[。!?!?…”"』」))】]$/.test(last.trimmed)) return [];
  return [{
    line: last.lineNo,
    column: last.trimmed.length,
    type: 'truncated',
    severity: 'blocking',
    message: '疑似截断:正文末尾未以句末/收尾标点结束,可能被模型中途切断;补完结尾或重写收尾。',
    excerpt: compact(last.trimmed.slice(-24)),
  }];
}

function findPlaceholders(content) {
  const findings = [];
  for (const { trimmed, lineNo } of content) {
    if (!isContent(trimmed)) continue;
    const dialogue = isDialogueLike(trimmed);
    for (const { re, label, hard } of PLACEHOLDER_PATTERNS) {
      if (!hard && dialogue) continue; // soft 拒绝语在对话行里可能是正常台词,豁免
      const m = re.exec(trimmed);
      if (m) {
        findings.push({
          line: lineNo,
          column: (m.index || 0) + 1,
          type: 'placeholder-leak',
          severity: 'blocking',
          message: `${label}:正文混入元信息/拒绝语/占位符,重写本段干净落地。`,
          excerpt: compact(trimmed.slice(Math.max(0, (m.index || 0) - 4), (m.index || 0) + 20)),
        });
        break; // one finding per line is enough
      }
    }
  }
  return findings;
}

function findMetaLeak(content) {
  const findings = [];
  let firstContentSeen = false;
  for (const { trimmed, lineNo } of content) {
    if (!isContent(trimmed)) continue;
    if (!firstContentSeen) {
      firstContentSeen = true;
      // 标题行(第N章 章名,无 ## 前缀时也算)属「标题行以外的正文」之外,排除
      if (/^第[一二三四五六七八九十百千万两0-9]+章/.test(trimmed)) continue;
    }
    const dialogue = isDialogueLike(trimmed);
    let m = META_TIER1_RE.exec(trimmed);
    if (m) {
      // tier1 纯工程词正文里几乎永不合法→blocking;但写手/编剧题材里角色在故事内真讨论创作,
      // 台词(对话行)里可能合法,降级为 advisory(仍报告,交人/LLM 判,不强制回炉)。
      findings.push({
        line: lineNo,
        column: m.index + 1,
        type: 'meta-leak',
        severity: dialogue ? 'advisory' : 'blocking',
        message: `工程词泄漏:「${m[0]}」是写作流水线术语,正文里不该出现;改成角色/场景内表达。${dialogue ? '例外:角色为作者/编剧、在故事内真实讨论创作时,台词里可能合法。' : ''}`,
        excerpt: compact(trimmed.slice(Math.max(0, m.index - 6), m.index + 18)),
      });
      continue; // tier1 命中即可,不再叠 tier2
    }
    m = META_TIER2_RE.exec(trimmed);
    if (m) {
      findings.push({
        line: lineNo,
        column: m.index + 1,
        type: 'meta-leak',
        severity: 'advisory',
        message: `元信息泄漏:「${m[0]}」疑似工程/章节结构词混入正文;改成角色当下可感知的事件锚点或相对时间。例外:角色在故事内真实阅读/讨论「第X章」、真身为作者/读者、或故事内系统/界面用语。`,
        excerpt: compact(trimmed.slice(Math.max(0, m.index - 6), m.index + 18)),
      });
    }
  }
  return findings;
}

function hasYamlFrontMatter(lines) {
  if (!lines[0] || lines[0].trim() !== '---') return false;
  let sawYamlField = false;
  for (let i = 1; i < Math.min(lines.length, 40); i += 1) {
    const trimmed = lines[i].trim();
    if (trimmed === '---') return sawYamlField;
    if (/^[A-Za-z0-9_-]+:\s*/.test(trimmed)) sawYamlField = true;
  }
  return false;
}

function compact(text) {
  const normalized = text.replace(/\s+/g, ' ').trim();
  return normalized.length > 80 ? `${normalized.slice(0, 77)}...` : normalized;
}
scripts/normalize-punctuation.js
#!/usr/bin/env node
'use strict';

const fs = require('fs');
const path = require('path');

const USAGE = `Usage: node normalize-punctuation.js [--check] [--quote-mode keep|ascii|yan] <file...>

Normalize正文 punctuation deterministically:
  - replace ellipses, em dashes, and double hyphens with Chinese punctuation
  - remove markdown divider lines (---) from正文
  - keep quote style by default; convert quotes only when explicitly requested
`;

const options = {
  check: false,
  quoteMode: 'keep',
  files: [],
};

for (let i = 2; i < process.argv.length; i += 1) {
  const arg = process.argv[i];
  if (arg === '--check') {
    options.check = true;
  } else if (arg === '--quote-mode') {
    const value = process.argv[i + 1];
    if (!value) die('--quote-mode requires keep, ascii, or yan');
    options.quoteMode = value;
    i += 1;
  } else if (arg.startsWith('--quote-mode=')) {
    options.quoteMode = arg.slice('--quote-mode='.length);
  } else if (arg === '-h' || arg === '--help') {
    process.stdout.write(USAGE);
    process.exit(0);
  } else if (arg.startsWith('-')) {
    die(`Unknown option: ${arg}`);
  } else {
    options.files.push(arg);
  }
}

if (!['keep', 'ascii', 'yan'].includes(options.quoteMode)) {
  die(`Invalid --quote-mode: ${options.quoteMode}`);
}
if (options.files.length === 0) {
  die('No files provided');
}

let totalFindings = 0;
let changedFiles = 0;
let failed = false;

for (const file of options.files) {
  const fullPath = path.resolve(file);
  let input;
  try {
    input = fs.readFileSync(fullPath, 'utf8');
  } catch (error) {
    failed = true;
    console.error(`${file}: unable to read (${error.message})`);
    continue;
  }

  const result = normalizeDocument(input, options.quoteMode);
  totalFindings += result.findings.length;

  if (options.check) {
    for (const finding of result.findings) {
      console.log(`${file}:${finding.line}:${finding.column}: ${finding.type}: ${finding.message}`);
    }
    continue;
  }

  if (result.output !== input) {
    fs.writeFileSync(fullPath, result.output, 'utf8');
    changedFiles += 1;
    console.log(`${file}: normalized (${result.findings.length} issue${result.findings.length === 1 ? '' : 's'})`);
  }
}

if (failed) {
  process.exit(2);
}
if (options.check && totalFindings > 0) {
  process.exit(1);
}
if (!options.check) {
  console.log(`Done. Changed files: ${changedFiles}`);
}

function die(message) {
  console.error(message);
  console.error(USAGE.trimEnd());
  process.exit(2);
}

function normalizeDocument(input, quoteMode) {
  const newline = input.includes('\r\n') ? '\r\n' : '\n';
  const trailingNewline = input.endsWith('\n');
  const lines = input.split(/\r?\n/);
  if (trailingNewline) lines.pop();

  const findings = [];
  const outputLines = [];
  let fence = null;
  let inFrontMatter = hasYamlFrontMatter(lines);
  let quoteOpen = false;

  for (let index = 0; index < lines.length; index += 1) {
    const lineNo = index + 1;
    let line = lines[index];
    const trimmed = line.trim();

    if (inFrontMatter) {
      outputLines.push(line);
      if (index > 0 && trimmed === '---') inFrontMatter = false;
      continue;
    }

    if (fence) {
      outputLines.push(line);
      if (isClosingFence(line, fence)) fence = null;
      continue;
    }

    const openingFence = parseOpeningFence(line);
    if (openingFence) {
      fence = openingFence;
      outputLines.push(line);
      continue;
    }

    if (trimmed === '---') {
      findings.push({
        line: lineNo,
        column: line.indexOf('-') + 1,
        type: 'markdown-divider',
        message: '正文中不要使用 markdown 分隔线;建议移除该行。',
      });
      continue;
    }

    const punctuationResult = normalizePausePunctuation(line, lineNo);
    findings.push(...punctuationResult.findings);
    line = punctuationResult.line;

    const quoteResult = normalizeQuotes(line, quoteMode, quoteOpen, lineNo);
    findings.push(...quoteResult.findings);
    line = quoteResult.line;
    quoteOpen = quoteResult.quoteOpen;

    outputLines.push(line);
  }

  return {
    output: outputLines.join(newline) + (trailingNewline ? newline : ''),
    findings,
  };
}

function parseOpeningFence(line) {
  const match = line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/);
  if (!match) return null;

  const marker = match[1];
  const rest = match[2];
  if (marker[0] === '`' && rest.includes('`')) return null;

  return { marker: marker[0], minimumLength: marker.length };
}

function isClosingFence(line, fence) {
  const marker = fence.marker === '`' ? '`' : '~';
  const match = line.match(new RegExp(`^ {0,3}(${marker}{3,})[\\t ]*$`));
  return Boolean(match && match[1].length >= fence.minimumLength);
}

function normalizePausePunctuation(line, lineNo) {
  const findings = [];
  const original = line;
  const pattern = /…+|\.{3,}|——|—|--+/g;
  let output = '';
  let lastIndex = 0;
  let match;

  while ((match = pattern.exec(original)) !== null) {
    output += original.slice(lastIndex, match.index);
    const token = match[0];
    const replacement = choosePauseReplacement(original, match.index, token.length);
    output += replacement;
    findings.push({
      line: lineNo,
      column: match.index + 1,
      type: getPauseType(token),
      message: replacement ? `替换为「${replacement}」。` : '移除重复标点。',
    });
    lastIndex = match.index + token.length;
  }

  output += original.slice(lastIndex);
  return { line: output, findings };
}

function hasYamlFrontMatter(lines) {
  if (!lines[0] || lines[0].trim() !== '---') return false;
  let sawYamlField = false;
  for (let i = 1; i < Math.min(lines.length, 40); i += 1) {
    const trimmed = lines[i].trim();
    if (trimmed === '---') return sawYamlField;
    if (/^[A-Za-z0-9_-]+:\s*/.test(trimmed)) sawYamlField = true;
  }
  return false;
}

function getPauseType(token) {
  if (token.startsWith('-')) return 'double-hyphen';
  if (token.includes('—')) return 'em-dash';
  return 'ellipsis';
}

function choosePauseReplacement(text, start, length) {
  const before = previousNonSpace(text, start - 1);
  const after = nextNonSpace(text, start + length);
  const rest = text.slice(start + length).trimStart();

  // 正文产物不保留 `……`、`——`、`—` 或 `--`;对话打断和数字区间不设例外。
  if (before === '') return '';
  // 紧跟开引号/开括号的停顿符号属于句首边界,删空即可,避免产出 `「,…」` 或 `「。」`。
  if (isOpeningDelimiter(before)) return '';
  if (/\d/.test(before) && /\d/.test(after)) return '到';
  if (isClosingQuote(after)) return isSentencePunctuation(before) ? '' : '。';

  if (!after) return isSentencePunctuation(before) ? '' : '。';
  if (isSentencePunctuation(before) || isPunctuation(after)) return '';
  if (/^(因为|原来|这是|那是|也就是|换句话|说白了|所谓|答案|原因|结果|真相|问题在于)/.test(rest)) return ':';
  if (/(原因|答案|真相|结果|结论|问题|选择|意思)$/.test(text.slice(0, start).trim())) return ':';
  return ',';
}

function previousNonSpace(text, index) {
  for (let i = index; i >= 0; i -= 1) {
    if (!/\s/.test(text[i])) return text[i];
  }
  return '';
}

function nextNonSpace(text, index) {
  for (let i = index; i < text.length; i += 1) {
    if (!/\s/.test(text[i])) return text[i];
  }
  return '';
}

function isSentencePunctuation(ch) {
  return /[,,。.!!??;;::…]$/.test(ch || '');
}

function isPunctuation(ch) {
  return /[,,。.!!??;;::、…"“”'‘’」』))]/.test(ch || '');
}

function isClosingQuote(ch) {
  return /["”」』]/.test(ch || '');
}

function isOpeningDelimiter(ch) {
  return /[「『((“‘]/.test(ch || '');
}

function normalizeQuotes(line, quoteMode, quoteOpen, lineNo) {
  if (quoteMode === 'keep') {
    return { line, findings: [], quoteOpen };
  }

  const findings = [];
  let output = '';

  for (let i = 0; i < line.length; i += 1) {
    const ch = line[i];
    if (quoteMode === 'ascii' && /[「」『』“”]/.test(ch)) {
      output += '"';
      findings.push({ line: lineNo, column: i + 1, type: 'quote-style', message: '按显式 quote-mode 转为半角双引号。' });
      continue;
    }
    if (quoteMode === 'yan' && (ch === '"' || ch === '“' || ch === '”')) {
      const replacement = quoteOpen || ch === '”' ? '」' : '「';
      output += replacement;
      quoteOpen = replacement === '「';
      findings.push({ line: lineNo, column: i + 1, type: 'quote-style', message: '按显式 quote-mode 转为盐言引号。' });
      continue;
    }
    output += ch;
  }

  return { line: output, findings, quoteOpen };
}