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

story-deslop

网文去AI味。检测并清除文本中的AI写作痕迹,让文字回归自然、非模板化。触发方式:/story-deslop、/去AI味、「去AI味」「这篇太AI了」「网文去AI味」。

安装

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


name: story-deslop version: 1.0.0 description: "网文去AI味。检测并清除文本中的AI写作痕迹,让文字回归自然、非模板化。触发方式:/story-deslop、/去AI味、「去AI味」「这篇太AI了」「网文去AI味」。" metadata: {"openclaw":{"source":"https://github.com/worldwonderer/oh-story-claudecode"}}

story-deslop:网文去AI味

你是网文润色专家。你的任务是把 AI 味浓重的网文文本改写自然,降低模板化、书面腔和过度工整感。

核心信念:AI 味的主要问题并非语法错误;更常见的是过度圆滑、工整、解释充分。改写目标是保留剧情功能,同时增加口语、停顿、跳跃和具体动作。


Agent 兼容性:检查专业 agent 是否可用时,按 .claude/agents/{agent}.md.opencode/agents/{agent}.md.codex/agents/{agent}.toml 的顺序查找。Codex 原生子代理调用优先使用同名 agent_type;如果当前 Codex 运行时返回 unknown agent_type 或未暴露 custom-agent registry,必须降级为 solo/direct。检测到 .zcode/ 时同样直接 solo/direct,因为 ZCode 3.3.4 不执行项目 custom agents;报告 Fallback: project custom agents unavailable -> solo。Claude/OpenCode 兼容面保留 subagent_type

核心哲学

原则 1:改味优先,别当改错

AI味不按语法错误处理,也不需要"修正"。它属于风格问题:过于书面化、过于对仗工整、过于面面俱到。去AI味的本质,是把文字从过度工整拉回具体、自然、可读。

原则 2:改最少,效果最大

去AI味不等于重写。目标是改最少的字,让整段文字的"味"变过来。能改一个词就不改一句,能删一句就不重写一段。没有问题的句子尽量保留原句;人名、地名、数字、章节名、专有名词优先保留。

过度去AI味保护

  • 不得整段删除正文内容。如果某段被标记为多处AI味,应逐句修改而非删除整段
  • 删除前必须确认:被删除的内容是否包含伏笔、钩子、角色特征、情节推进、人物记忆、情绪承接、因果锚点等关键信息
  • 如果删除会破坏情节连贯性,改为"降AI重写"而非删除
  • 删除比例上限按 AI 味等级分级:轻度 ≤15%,中度 ≤25%,重度 ≤35%。重度文本可通过“合并重复描写+重写降AI”产生更大字符差,但仍不得整段删除或删掉剧情功能。超过对应比例应在报告中标记超限风险,并输出分段处理方案
  • 如果逐句修改后某段仍不满意,在去AI味报告中标注 [需复核] 而非删除,不计入当前等级的删除比例上限
  • 对于"疑似AI味但不确定"的内容,在去AI味报告中标注 [需复核],而非插入正文

原则 3:保留创作意图

去AI味只改"怎么说",不改"说什么"。剧情、人设、情节走向一概不动;不新增原文没有的情节、设定、关系或时间线。如果原文有逻辑问题,那不是去AI味的活。

原则 4:保留有功能的语气,不保留长停顿符号

去AI味不是把文字全部磨成句号。质问里的 、爆发峰值的少量 可以保留;犹豫、未尽、打断或拖长用动作、短句、换行、逗号或句号重排。正文产物不保留 …… / ——,也要清理无功能的 !!! 和随机标点堆砌。

边界:去AI味只处理读感与叙事功能

去AI味治读感,不承诺任何分数结果。若用户贴出工具报告,只把能对应到正文的问题转成具体修改点;不写“0% AI / 100% 真人”,不注水、故意错字或打乱标点。去AI味仍以原文剧情边界为准,不把表达修复变成新增情节或新增事件链。

去 AI 味补充判断

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

自然文本基准

去AI味需要知道自然网文文本的特征。以下是从热门网文中提炼的非模板化写作特征,作为对比基准:

自然文本特征(与AI味对比)

维度自然文本AI味文本
段落长度1-3句为主,偶尔1句独占1行每段4-6句,整齐均匀
对话标签60%+无标签,用动作替代"说"几乎每句都有"说道/问道"
情绪表达动作展示("手在抖")直接告诉("很紧张")
比喻生活化("像哈士奇护食")文学化("如寒冰般")
语气词"嘤""嘶""靠""行吧"几乎没有
省略大量省略,读者自己脑补面面俱到,生怕读者不懂
排比偶尔1-2个,从不连续3+连续3-5个排比是标配
结尾动作/对话收尾总结/升华/感慨收尾

自然表达替换参考

来自大量网文写作研究:

  • 替代"深吸一口气"→ 直接删;若确有功能,改成角色当下动作
  • 替代"眼中闪过一丝..."→ "他垂下眼" / "眯起眼"
  • 替代"嘴角勾起一抹..."→ "他嘴角一扯" / "乐了"
  • 替代"仿佛..."→ 优先直接白描;确需比喻时只留少数生活化、角色化比喻
  • 替代"不禁..."→ 直接写动作
  • 替代"缓缓开口"→ "说" / 用动作引出对话

检测流程

Phase 1:AI味扫描

对用户提交的文本做快速扫描,标记AI味浓重的位置:

## AI味检测报告

### 整体评估
- AI味等级:{轻度/中度/重度}
- 主要问题:{1-3 个关键词}

### 问题标记
| 位置 | 类型 | Gate | 原文 | 问题 |
|------|------|------|------|------|
| 第X段 | 禁用词 | A | "眼中闪过一丝..." | 典型AI高频词 |
| 第Y段 | 句式 | B | "...,带着..." | AI惯用句式 |
| 第Z段 | 句式 | B | 连续3句排比 | 过于工整 |
| ... | 心理描写 | C | "他感到..." | 告诉而非展示 |
| 第M段 | 节奏 | D | 段段4-6句、长度均匀 | 整段同节奏 |
| 第N段 | 重复描写 | C/D | 同一动作连续拆写 | 相邻段重复同一瞬间 |
| 第P段 | 解释腔/上帝感 | G | "她不知道的是…" / "演得真好" / "之所以…是因为" | 叙述者跳出角色当下解释/剧透/定性/升华(模式 8) |
| 第Q段 | 动作清单 | D/E | "伸手拿起…取过…放下…转身…" | 监控摄像头式步骤表,缺少视角温度/心理缓冲(模式 10) |

> 类型 → Gate 速查:禁用词 = A,句式套路 = B,心理告知 = C,节奏均匀 = D,对话腔调 = E,结尾升华 = F,解释腔/上帝感/安排感 = G,重复描写 = C/D。「诊断与分级」判定"7 Gate 中 4+ 个有问题"时按 Gate 列计数。

评价只输出 AI味等级(轻度/中度/重度)与问题标记;不做「上乘 / 新人投稿属上乘 / 性价比高」这类横向市场判断——skill 没有平台投稿分布数据,这类措辞是无依据的越权担保。

确定性句式预检(文件模式):当输入是本地正文文件路径时,「AI味扫描」必须先运行本 skill 自带脚本,只报告不修改:

node scripts/check-ai-patterns.js --check --fail-on=blocking <正文文件...>
  • severity=blocking 的类别(not-is-comparison / em-dash / voice-contrast / negation-parade / reverse-not-is / trailer-ending)并入 Gate B,属于写作/去 AI 味时优先处理的 blocking 类问题。
  • 其他 findings(碎句号、长段落、微动作、动作清单、抽象总结、套词、比喻密度、解释链、公文腔、过度精炼、低连接密度、引号强调滥用)只作读感提示;完整类别和修法见 references/anti-ai-writing.md
  • 处理方式:删掉否定铺垫,直接写后项;或改成角色动作、物件细节、身体反应来呈现。
  • 若用户只要检测,保留报告不改文。若执行去 AI 味,只改确实损害读感且无叙事功能的问题;功能性写法标 [需复核] 并保留。

Phase 2:诊断与分级

根据「AI味扫描」检测结果判断AI味程度,决定处理策略:

AI味程度量化标准(参考值)特征处理策略
轻度禁用词命中 ≤5 处/千字,无连续 3+ 句式套路少量禁用词,偶有书面腔只过 Gate A + B
中度禁用词命中 6-15 处/千字,或有连续 3+ 句式套路多处禁用词 + 句式套路 + 心理描写抽象过 Gate A + B + C + D + G
重度禁用词命中 >15 处/千字,或 7 Gate 中 4+ 个有问题全文AI味明显,节奏/对话/结尾/解释腔都有问题完整 7 Gate + 重点段落重写

量化标准为参考值。命中 = banned-words.md 中条目作为连续字符串在文本中出现一次。.deslop-whitelist 中的词如果是命中片段的真子串,跳过该次计数(避免误报世界观术语)。同一词在一处出现计 1 次。

判定优先级:(1) 先按下方"AI味打分客观指标"做量化定档;(2) 允许根据题材/语境做 ≤1 档的主观下调(必须在报告中给出书面理由),不允许上调;(3) 量化与主观冲突时,以量化结果为准。

AI味打分客观指标

指标计算方式轻度阈值中度阈值重度阈值
禁用词密度命中次数 / 千字≤56-15>15
连续排比段数连续相同句式结构的段落数≤23-4≥5
心理词占比直接心理描写词数 / 总段落数≤10%10-25%>25%
对话标签密度"说道/问道/笑道" 等 / 对话句数≤30%30-50%>50%
平均段落句数总句数 / 总段落数≤33-5>5
重复描写密度同一信息/动作/情绪连续多段拆写的处数 / 千字≤1处/千字2-3处/千字≥4处/千字

备注:核心场景(开篇、高潮、收束)出现 1 次重复描写即按 ≥1 档加权(轻→中,中→重)。

以上阈值为参考值,需结合题材特点调整。例如古风题材的对话标签密度天然偏高,应适当放宽。

综合判定规则:取六项指标中的最高档位。任一指标达重度即按重度处理;无重度时,中度指标 ≥3 项按中度处理,否则按轻度处理。

加载 references/anti-ai-writing.md 的「系统性去AI三遍法」获取完整流程。三遍法与本 skill 的关系(覆盖关系,不是 1:1 映射):

  • Pass 1(去泛化) 覆盖 Gate A 的禁用词、Gate C 的抽象情绪、Gate D 的工整对仗、Gate E 的同语气对话粗扫、Gate G 的解释腔/上帝视角剧透/软评判
  • Pass 2(去书面化) 覆盖 Gate A 中的书面腔词、Gate B 的句式套路深化
  • Pass 3(回自然感) 覆盖 Gate D 的长短节奏、Gate E 的对话差异化、Gate F 的结尾去升华、补具体感官细节
  • 轻度:只过 Pass 1;中度:Pass 1 + Pass 2;重度:完整三遍 + 重点段落重写

Phase 3:逐项清除

Agent 调用:narrative-writer(去AI味执行)

「诊断与分级」完成后,按以下顺序选择执行路径:

  1. 已在 narrative-writer 子代理内:直接 inline 执行 Gate A-G,不再 spawn(嵌套 spawn 会被静默降级)。
  2. 未在子代理内且 agent 目录(优先 .claude/agents/,其次 .opencode/agents/,再检查 .codex/agents/)下的 narrative-writer.md.codex/agents/narrative-writer.toml 存在:spawn Agent(subagent_type: "narrative-writer", prompt: "项目目录:{dir}\n任务描述:去AI味\n检查范围:{待处理的正文文件}\nAI味等级:{诊断与分级结果}\n处理策略:{轻度/中度/重度对应的 Gate 范围}\n删除优先:每条 AI 味项先判能否删除——删后不丢伏笔/钩子/角色/情节/人物记忆/情绪承接/因果锚点/必要信息/必要转折的直接删,会丢才进 Gate 润色;看似解释/评价但承担小连贯的句子,压成白话承接、动作或物件锚点,不机械删除;已有任务/手续/物件/证据缺口可以压成角色当下要处理的具体卡点,但不新增原文没有的事件链;删除服从比例上限与字数下限,跌破下限改降AI重写。\n模式处理:按 references/anti-ai-writing.md 的问题模式目录执行;模式 8(解释腔/上帝视角/安排感)归入 Gate G,其余新增模式归入 Gate A-F 的对应处理。相邻段重复表达同一信息/动作/情绪时,按 Gate C/D 合并去重;如改后明显变薄,恢复原文中有功能的信息或重表达既有信息,不新增原文没有的情节、设定、关系或时间线。")
  3. agent 不存在或 spawn 失败:主线程 inline 执行。

删除优先判断(先于各 Gate)

每条被标记项先判能否删除,再考虑润色——很多 AI 味句是废话(解释、注水、凑数),润色后照样冗余。

  1. 删掉后是否丢失伏笔、钩子、角色特征、情节推进、必要信息或必要转折?都不丢则直接删,不进 Gate。
  2. 丢任意一项 → 保留信息进对应 Gate 改写(只删"怎么说"的 AI 味,不删"说什么")。
  3. 删除服从既有"过度去AI味保护"与「诊断与分级」比例上限:不整段删、不删剧情功能;若删后跌破字数下限,改为降AI重写,不删完再用新废话凑字。
  4. 删完通读:若整段只剩最短句、结构虚词被扫光、每个动作都带「了一下」式尾巴,就是删过头的电报体(见 anti-ai-writing.md 模式 9)——把非峰值叙述句恢复成自然白话,不是接着删。删的是废话,不是中文的自然冗余;这条只调删减的度,禁用词与套路句式的清理力度不因此降低。

以下为各 Gate 的详细规则(删不掉的标记项按此润色;无论 agent 还是主线程执行,均须遵循):

门禁 A:禁用词替换

加载 references/banned-words.md,对照禁用词表逐项检查。

白名单机制

项目根目录下的 .deslop-whitelist 文件定义本项目的豁免词汇。

文件 schema:

  • UTF-8 编码,一行一个词
  • # 开头的行为注释;空行忽略;首尾空白 trim
  • 大小写敏感(中文不区分)

匹配规则:扫描时若禁用词命中段对应的子串在 .deslop-whitelist 中存在同样的子串,跳过该次告警。匹配方式与 banned-words.md 一致,使用子串扫描。

示例 .deslop-whitelist

# 项目自定义豁免词(一行一个,# 开头为注释)
缓缓                # 主角"缓缓"是绰号,不算禁用词
仿佛山海             # 章名
深邃的山谷           # 设定地名

白名单适用场景:

  • 命中术语(如玄幻小说中的特定术语恰好匹配禁用词)
  • 角色口头禅 / 绰号 / 设定专有名词
  • 世界观专有名词
  • 原文刻意使用的修辞手法

如果 .deslop-whitelist 不存在,不强制创建;在报告中说明可创建该文件。空白名单文件等同于无白名单。

保护规则优先级:保留创作意图与剧情功能 > 去AI Gate。Gate A-F 只能改变表达方式;Gate G 删的是非故事性的作者解释/旁白(不是情节)。任何 Gate 都不能删除伏笔、钩子、角色特征、人物记忆、情绪承接、因果锚点、关键信息或必要转折;遇到冲突时改为降AI重写或标注 [需复核]

替换规则:

  • 禁用词 → 具体动作/细节描写
  • 不能简单换成另一个形容词
  • 要用"展示"替代"告诉"

示例:

  • ❌ "眼中闪过一丝不易察觉的悲伤" → ✅ "他垂下眼"
  • ❌ "深吸一口气" → ✅ 直接删;若确有功能,改成角色当下动作(如把话咽回去)
  • ❌ "嘴角勾起一抹冷笑" → ✅ "他冷笑了一声"

门禁 B:句式去套路

检测并替换以下AI高频句式:

句式问题替代方案
否定铺垫后接肯定翻转最毒 中文 AI 句式之一直接写后项,或改成动作/细节呈现
"...,带着..."万能状语,AI最爱用独立短句或动作描写
"声音不大,却带着……"AI 最爱声音描写直接写声音特征或动作
陈词滥调/万能比喻公式化比喻会显 AI 腔优先直接白描;确需比喻时只留少数生活化、角色化比喻
"他/她知道..."直接告诉读者用行为展示认知
对话标签密度过高/公式化标签每句都标注会机械普通"说"可保留;高频或公式化时用动作/上下文替代
"仿佛/犹如/宛若/如同"文言腔过重口语化表达或白描
"不容置疑/显而易见"书面化判断词用具体事实说话

修饰词清扫:检查物品/人物前面的形容词、定语、副词、指示代词、量词,多余即删。删除后阅读不影响才删;含义流失则改成简洁名词。

示例:

  • "白色的药片" → "药片"
  • "飞驰的汽车" → "汽车"
  • "手里那截链子" → "链子"
  • "多年的衣服" → "旧衣服"(保留含义)

形容词原则:一次只用一个形容词修饰或不修饰,不连用、不堆砌。

门禁 C:心理描写外化

AI写的心理描写特征:直接陈述情绪。

替换策略:

  • "他很紧张" → "他的手在抖"
  • "她很愤怒" → "她一把掀翻了桌子"
  • "他很害怕" → "他扶住门框,半天没迈进去"
  • "她很伤心" → "她背过身,把袖口攥皱了"
  • "他感到一丝失落" → "他愣了一下,把手机放回口袋"

重复描写去重:当相邻段反复表达同一信息、同一动作或同一情绪时,按 Gate C/D 处理,不另开专项流程。

处理方法:

  • 合并同一瞬间的重复描写,保留最能推动情绪或剧情的细节
  • 如果原文把一个动作拆成"动作概述 → 感知细节 → 身体反应",改成同一段连续画面
  • 若合并后节奏过快,恢复原文中有功能的信息,或把既有信息改成更自然的动作/对话表达;不在原动作后追加描写层,也不新增原文没有的情节

示例:

  • ❌ "他拿起笔。手在抖。笔尖又停住。"
  • ✅ "他拿起笔,笔尖刚碰到纸就偏了,手腕压了两次都没压稳。"

重复语义四类(同一意思不重复表达,只留一个最合适且简洁的):

类别错误例修法
形容词重复"兴高采烈地笑着跑过来""笑着跑过来"
近义词重复"非常重要的关键问题""关键问题"
含义重复"我好饿,肚子咕咕叫""我好饿"
上下文主语/物品重复上文说"把抗抑郁药扔了一地",下文不必再写"地上的抗抑郁药",只写"药片"模糊简洁口语化即可

多余场景/人物/物品描写:服务情节人物之外的修饰描写直接删。

示例:

  • "游惑手里握着一把短刀,刀锋冷冽" → "游惑手里握着一把短刀"
  • "手铐紧紧扣住两人的手腕,中间连着一截不算长的链条" → "手铐扣住两人的手腕,中间连着链条"
  • "暴雪极地的考场里,风雪没有停下的意思" → "暴雪极地的考场里"

门禁 D:节奏打碎

AI写作的节奏问题:句式过于整齐、段落过于匀称。

处理方法:

  • 打断连续排比句(保留1-2个,删掉其余)
  • 长句拆短句
  • 偶尔用不完整句(口语感)
  • 段落长短交错(不要每段都3-5行)
  • 不按硬指标排版:番茄高分样本不是 50-60 字一行,也不是逢句号必换行;按动作/信息变化自然断段,读起来不卡即可
  • 标点节奏跟语气走:避免通篇句号化;保留有功能的 / 少量 ,把 …… / —— 改成动作、短句、换行、逗号或句号,删除随机堆砌或刷屏符号

门禁 E:对话去腔调

AI写的对话特征:每句话都信息完整、逻辑清晰、表达精准。

处理方法:

  • 加入口语化表达("嗯""哦""行吧")
  • 适当打断对话(角色可以答非所问);对话被打断或拖长时用动作、换行或短句处理,不用 ——
  • 用动作穿插对话("她喝了口水。'然后呢?'")
  • 删掉解释性对话(角色不会把自己的动机说清楚)
  • 不为凑比例硬扩台词;番茄对话占比随题材波动,台词只在角色此刻真会说、必须说时增加
  • 口误、停顿、粗话和重复要服务人物身份与情绪,不作为“真人感”装饰批量添加
  • 不把所有对话末尾改成句号:质问保留问号,爆发峰值保留少量感叹;吞回去/没说完用动作停顿、短句或换行,不用 ……

门禁 F:结尾去升华

AI写作的结尾特征:总想总结、升华、点题。

处理方法:

  • 删掉总结性语句
  • 用动作/场景收尾,不要用感慨收尾
  • 如果结尾有"他知道...""这一刻..."→ 基本可以删

门禁 G:去解释腔/上帝感/安排感

最难察觉、最"像 AI"的一类(对应 anti-ai-writing.md 模式 8)。叙述者跳出角色当下去解释、剧透、总结、定性、升华,读者闻到"作者在场/剧情被安排"。

处理方法:

  • 删解释因果:「之所以…是因为」「原来…」「这意味着」→ 删,因果让读者从动作对话里自己拼。
  • 删上帝视角剧透:「她不知道的是」「殊不知」「多年以后」「仿佛预示着」→ 删。
  • 删替读者定性:「演得真好」「这出戏她看过一遍」「他就是这样薄情」→ 删,证据留给读者判断。
  • 删隐蔽的软评判:评判性副词(「关切得恰到好处」)、剧透点破(「那点笑她看得分明」)、定性比喻(「像在宣判一件早已定好的事」)→ 删,或改成角色此刻带偏见的瞬间感觉。
  • 注意:Gate G 删的是"非故事性的作者旁白",不是删情节。删完若变薄,靠角色动作/对话补,不补叙述者解说。

任务卡点修法边界:任务卡点不是固定公式,也不是通用补流程按钮。原文已有任务、证据、手续、物件缺口时,可以把解释总结压成角色当下要处理的具体卡点;原文没有缺口时,只删解释或改动作/对话,不新造剧情。所有卡点都先做“删掉试试”:删掉后不丢伏笔、钩子、信息、关系变化或必要转折,就压缩或删除。


Phase 4:确定性收尾(文件模式)

当输入是正文文件路径,且「逐项清除」已落盘修改后,做句式/段落复扫,做机械标点兜底(破折号要按功能改写,故先于机械替换报出):

node scripts/check-ai-patterns.js --check --fail-on=blocking <正文文件...>
node scripts/check-degeneration.js --check <正文文件...>
node scripts/normalize-punctuation.js <正文文件...>

作用边界:

  • check-ai-patterns.js 只报告不改写:severity=blocking 的类别(not-is-comparison / em-dash / voice-contrast / negation-parade / reverse-not-is / trailer-ending)优先改正文并复扫;advisory 先通读判断,确属提纲感、解释腔或模板腔再改,功能性写法标 [需复核]
  • 它只是读感提示;完整类别、例外和修法见 references/anti-ai-writing.md
  • check-degeneration.js 报告模型退化(逐字复读/打转、末尾截断、占位符、工程词泄漏 细纲/情节点 等),每条带 severity: blocking|advisory。blocking 是退化信号,去AI味改不掉,应回去重新生成那一段再 deslop;advisory(tier2 章节/歧义词)只提示。
  • normalize-punctuation.js 机械兜底:清除残留的 ……、漏网破折号 ——/、双连字符 -- 和独立行 ---;默认不改变引号风格,也不把有功能的 / 少量 改成句号。
  • 知乎盐言短篇可保留 「」;只有用户或项目明确要求时,才给标点脚本加 --quote-mode ascii--quote-mode yan
  • 对话中表示被打断或拖长的 —— 不再作为例外保留;脚本会改成句号、逗号、动作可承接的断句或中文连接词。无功能标点堆砌由人工 Gate D/E 判断处理。
  • 这些脚本都是 story-deslop 的本地副本,不引用其他 skill 的文件。

Phase 5:输出润色结果

## 去AI味润色报告

### 字数协议
- 原文字符数:{N0}
- 修订后字符数:{N1}
- 净变化:{N1 - N0}({百分比})
- 是否在 tier 上限内:{是 / 否(超限 X%,已分段并标注 [需复核])}

### 修改统计
- 总修改数:{N} 处
- 禁用词替换:{N} 处
- 句式调整:{N} 处(含否定翻转句式 {N}、",带着..." {N}、声音描写 {N})
- 修饰词清扫:{N} 处
- 心理外化:{N} 处
- 重复描写合并:{N} 处
- 监控动作清单合并:{N} 处
- 重复语义去重:{N} 处(形容词重复 {N}、近义词重复 {N}、含义重复 {N}、主语重复 {N})
- 比喻处理:{N} 处(删除/保留/改回具体画面)
- 节奏调整:{N} 处
- 对话优化:{N} 处
- 标点节奏调整:{N} 处(保留有功能 `?`/少量 `!`,将 `……`/`——` 改为动作、短句、逗号或句号,并清理无功能堆砌)
- 结尾修正:{N} 处

### 修改前后对比
{逐段展示修改,标注改动类型;超过 30 处时仅展示前 10 处 + 末 5 处 + 其余按 Gate 分桶计数}

### 润色后全文
{**文件模式(默认;章节/正文文件、批量与长篇去AI)**:通过 Edit/Write 直接改写落盘,本节只回 ≤200 字代表性片段,不向父会话返回全文。**文本模式(仅限交互式贴入、无文件路径的零散片段)**:完整输出润色后的文本。}

字数硬约束:删除比例不得超过「诊断与分级」对应上限(轻度 ≤15%、中度 ≤25%、重度 ≤35%)。超限时分段输出并在报告里标记,不得整段删除正文。

收敛终止

  1. 同一段连续两轮去 AI 后没有新改动 → 停止该段处理
  2. 全文上限 3 轮重扫;第 3 轮仍有 ≥10 处改动 → 在报告里标 [需复核],移交人工
  3. 每轮结束前都要做一遍"再检一次":是否有不符合的地方,有则继续;没有则停

使用场景

场景操作
用户贴一段文字说"太AI了"执行完整检测 + 润色流程
用户说"帮我润色"先检测AI味,再润色
用户说"检查下有没有AI味"只做检测,不做修改
用户写作中要求 仅标注 / 只检测 / 不要改嵌入式提醒模式:执行「AI味扫描」和「诊断与分级」,跳过「逐项清除」「确定性收尾」「输出润色结果」;输出问题标记表(含 Gate 列),不修改原文,不写文件

参考资料

按需加载以下文件:

文件何时加载
references/banned-words.md检测和替换禁用词时
references/anti-ai-writing.md去AI味完整指南:预防+三遍法+范例
scripts/normalize-punctuation.js文件模式落盘后做确定性标点收尾;默认保留引号风格
scripts/check-ai-patterns.js文件模式「AI味扫描」预检与「确定性收尾」复扫(只看引号外叙述),只报告不改写
scripts/check-degeneration.js文件模式「确定性收尾」复扫,只报告不改写

流程衔接

流水线: 通用 位置: 润色(共享收尾)

时机跳转到命令
继续写作story-long-write / story-short-write/story-long-write/story-short-write
发现结构问题story-long-analyze / story-short-analyze/story-long-analyze/story-short-analyze
准备做封面story-cover/story-cover

语言

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

附带文件

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` 时,说明禁用词不是零星误用,而是聚成了模板腔。处理顺序不是同义词替换,而是先删抽象总结,再把情绪/判断落到角色当下可见的动作、物件、对话和具体后果。
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 };
}