返回 Skills 目录
zenstory-ai/oh-story-claudecode已通过检查

SKILL DETAIL

story-deslop

zenstory-ai/oh-story-claudecode/story-deslop

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

安装量 · 90查看来源

Installation

npx skills add https://github.com/zenstory-ai/oh-story-claudecode --skill story-deslop

技能文件

SKILL.md

最近同步 · 2026年8月30日

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` 未覆盖的高阶替换):

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

### 引号只承载真实引用,不给普通名词加戏

不要用双引号给普通名词、常见动作或作者临时概括的概念做“引号强调”。这类写法会把没有特殊含义的词硬包装成术语,连续出现时尤其像模型在替读者划重点。`check-ai-patterns.js` 的 `quote-emphasis-tic` 只负责提示,最终按语境判断。

- **应改**:所谓的"机会"、完成这次"蜕变"、找到真正的"答案"。这些词若只是普通语义,直接去掉引号,用事件本身体现分量。
- **应保留**:角色对话、逐字直接引用、书名/篇名、确有设定含义的代号,以及手机消息、公告、系统播报等场内载体展示的原文。
- **边界**:第一次定义术语时可以用引号,但后文不要反复加;讽刺、反话或角色刻意咬重音时可以保留,前提是上下文能看出是谁在强调、为什么强调。

### 章末总结体

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

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

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

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

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

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

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

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

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

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

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

---

## 核心规则

> **句长以规则 3 为准**:规则 1-4 和本文件其他地方的「短句 / 拆短 / 能删就删」说法,与规则 3 冲突时按规则 3 执行。

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

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

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

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

更自然:他盯着窗外的雨,雨从下午下到天黑。
"你还在想她?"老刘问。
他没说话。
```

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

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

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

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

以上替换针对关键情绪节点;低强度过场情绪可一笔直写("他有点烦"),不必处处外化。

### 规则 3:句子该多长(短句是工具,不是默认)

叙述(旁白)默认写成**逗号长句**:一句用逗号串起 2-4 个动作或信息,再落句号;逗号之间 8-12 字,整句 20-30 字。短句是偶尔的孤立重拍工具,不是叙述的默认写法。

| 场景 | 句长 | 示例(长篇语料原句) |
|------|------|------|
| 日常 / 推进 / 描写(多数叙述句) | 逗号之间 8-12 字,整句 20-30 字 | 阴冷潮湿的气息扑面而来,身下铺着一层薄薄的稻草,湿漉漉地粘在皮肤上。 |
| 对话 | 口语化,长短随角色 | "你疯了?""可能吧。" |

**不合格(与 AI 腔同级)**:
- 逗号之间连着都是 ≤5 字的碎片("他抬手,开门,进屋,坐下"式)
- 通篇 3-8 字句、句号密得像提纲(电报体,见模式 9)
- 一长一短机械交替(同样是模板)

> **爆款语料校准**(七猫长篇 现言/都市/古言/玄幻/历史 125 本×前 8 章旁白统计):逗号之间平均 8.8-9.6 字;整句平均 22-24 字;逗号长句占叙述句 74-80%;≤5 字的短片段约占两成,多是孤立的时间词、转折、动作重拍。短篇(盐言体)段落更短(≤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 喜欢把事情凑成三个以显"完整"。-> 砍到只剩最有力的一条。

跨段「不是A。/也不是B。/只是C。」由 `formulaic-parallelism` 作 advisory:它可能是工整铺排,也可能承担辩解、悬念排除或情绪递进;只有重复提纲、拖慢画面时才压缩。该类提示与「至于X不X,怎么X」、同动词「不V A,不V B」都只作语义复核:对话也要检查,但有明确人物声线或任务功能时可保留;若来自细纲多个字段对同一要求的重复,正文只能消费一次,不能逐项复述。

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

| 表现 | 例(删/改) |
|---|---|
| 解释因果 | 「之所以…是因为」「原来…」「这意味着」「正是因为」-> 删。因果只从角色动作、对话、反应里让读者自己拼 |
| 上帝视角剧透 | 「她不知道的是」「殊不知」「多年以后」「冥冥之中」「仿佛预示着」-> 删。只写角色此刻知道的,悬念让读者自己悬 |
| 替读者下结论/定性 | 「演得真好」「这出戏她看过一遍」「他就是这样薄情的人」-> 删。把证据(神态、动作、台词)摆出来,定性留给读者 |
| 替角色总结心理 | 「她明白,这一切都是命」-> 换成一句带偏见的闪念或一个身体反应 |
| 总结/动机/评价链把意义说满 | 「他终于明白」「这是最好的选择」「所有人都会记住这一刻」-> 删掉定性,改成角色当下要处理的具体缺口、未完成动作或局部反馈;不是保留评价再硬塞物件/动作 |
| 安排感/硬铺垫 | 为后文强行交代背景、整段回忆倒叙 -> 背景按角色此刻真实所需,用闪念、半句话、物件零碎带出,不集中交代 |
| 升华式收尾 | 结尾对仗拔高、金句点题 -> 用一个动作或一句留白收住,把"意思"压进画面里 |
| 抽象命运/开端收束 | 「命运终于露出獠牙」「早已布好的棋局」「这一刻终于明白」「属于他的反击才刚刚开始」-> 改成角色当下可见的文件、动作、对话或物理后果;`check-ai-patterns.js` 报 `abstract-summary-tic` 时优先处理 |
| 套词密度过高 | 仿佛/一丝/一抹/深吸一口气/平静无波/指节泛白等成串复现(`cliche-density-tic`)-> 不是同义词轮换,整段回到角色当下证据:文件、动作、对话、物理后果 |
| 套式反应细节 | 指尖轻叩、袖口里攥紧、指节泛白、目光移开、“语气平静得像在念……”等反应成片(`stock-reaction-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风打斗**
- '他的拳头犹如疾风骤雨般猛烈,每一击都蕴含着不容置疑的力量。对手的瞳孔微微收缩,显然没有预料到如此凌厉的攻势。'
- 他一拳怼过去,对方没躲开,嘴角破了。

### 结尾改写范例

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

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

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

### 节奏调整范例

> 以下范例处理的是臃肿修饰、堆叠比喻和抽象总结,不是「见长就拆」:改写后叙述仍以逗号长句为主(规则 3),不要把正常的逗号长句拆成短句串。

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

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

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

---

## 冲突对话改写范例

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

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

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

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

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

### 震惊分层改写范例

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

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

### 代入感修复范例

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

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

---

## 质量检查清单

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

- [ ] **段落控制**:段落按动作/信息变化断开,读起来不卡
- [ ] **正文无破折号**:正文(含叙述和对话)无 `——`/`—`/`--`(用句号、逗号、短句或动作断句),不设置对话例外
- [ ] **AI高频词扫描**:无不禁/仿佛/映入眼帘/心中暗道/沉声道/嘴角微扬/不由自主/只见
- [ ] **弱化副词计数**:每1000字"微微/淡淡/缓缓/轻轻"不超过3个
- [ ] **无三连排比**:没有AI式的"三个一组"修辞
- [ ] **工整否定清单已复核**:跨段「不是A / 也不是B / 只是C」及其他 `formulaic-parallelism` advisory 已连同台词逐条复核;功能性修辞可保留
- [ ] **无论文体**:无"不难看出/由此可见/事实上/综上所述"
- [ ] **无书面语连词堆砌**:无"于是乎/与此同时/从而/因而/诚然"泛滥
- [ ] **章尾无总结升华**:用动作/对话/悬念收束,无感悟/哲理/预告
- [ ] **无大段心理描写**:心理活动不超过2段,无括号标注内心
- [ ] **情绪用动作展示**:关键情绪节点无直接写"愤怒/伤心/紧张",用身体反应替代;低强度过场情绪可一笔直写,不必处处外化
- [ ] **对话口语化**:无书面腔,不同角色语气可区分
- [ ] **标点不压平**:没有把质问、爆发、犹豫全部压成句号;也没有随机堆砌 `?`/`!`,或用 `……`/`——` 硬造停顿
- [ ] **Show Don't Tell**:用行为代替形容词,用细节代替总结
- [ ] **句长达标**:叙述默认是逗号长句(逗号之间 8-12 字、整句 20-30 字,规则 3);短句只作偶尔的孤立重拍,用完回到逗号长句;没有连着的 ≤5 字碎片,没有通篇短句像提纲
- [ ] **detector advisory 逐条复核**:`micro-action-tic` / `stock-reaction-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/author-memory.md
# 作者记忆协议

作者记忆用于保存跨会话复用的创作偏好,不保存小说世界里的事实。它借鉴“原始证据 → 候选 → 已确认画像 → 变更记录”的记忆管道,但把决定权留给作者。

## 边界与优先级

加载优先级从高到低:

1. 安全、平台、字数、文件协议等硬性门禁;
2. 用户在当前请求中的明确要求;
3. 当前书的 `设定/文风.md`、题材定位、细纲和其他项目设定;
4. 作者记忆中的本书偏好;
5. 作者记忆中的题材、流程和全局偏好;
6. 对标素材、通用方法和默认值。

作者记忆不能把本书事实写进 `.story/作者记忆/`,不能覆盖当前请求,不能降低审稿 rubric,也不能让去 AI 味改动剧情意图。小说事实继续由各书的 `追踪/` 和 `设定/` 管理。

## 文件与所有权

工作区级目录:

```text
{工作区}/.story/作者记忆/
├── _author-memory-state.json  # 唯一结构化权威
├── 作者画像.md               # 仅 active,供作者查看与管理
├── 待确认.md                 # pending / conflict,不参与约束
└── 变更记录.md               # 最近 100 次、最新在前的事务记录
```

三个 Markdown 文件都从 state 确定性生成,禁止手改;完整历史保留在 state,变更记录只展示最近 100 次。`作者画像.md` 是人类管理视图,普通写作 agent 不整份注入,而是调用 `query` 取得本次相关的紧凑上下文。作者记忆不存在时,普通写作、审稿和去味任务直接继续,不自动初始化空目录;首次 `record` 会随事务创建。

工作区必须显式传给脚本。优先使用已经包含 `.story/作者记忆/` 的最近祖先;首次初始化时使用承载多本书、`.active-book`、`长篇/`、`短篇/` 或 `拆文库/` 的创作工作区根。不要把用户主目录当默认工作区。

## 什么时候读取

长篇、短篇、去 AI 味开始前,如果 state 已存在,用 `query` 按本书、题材、流程和类型筛选 active 条目。查询输出固定不超过 2048 字节。不要先查询全部再让 agent 自行筛选,按任务直接选择 kind:

| 任务 | query kinds | 注入位置 |
|---|---|---|
| 正文初稿 / 续写 | `prose_style` + `story_design` | 主会话与实际正文 agent |
| 去 AI 味 / 改写 | `prose_style` | 主会话与实际改写 agent |
| 设定 / 大纲 | `story_design` + `workflow` + `interaction` | 主会话,不传正文 agent |
| 审稿 | `delivery` + `interaction` + 必要的 `prose_style` | 主会话,不降低 rubric |

审稿匹配项只用于交付格式、协作方式和“作者有意采用的表达选择”说明;问题严重度和 PASS/FAIL 仍由 rubric 决定。

待确认项不进入 prompt 约束,也不应为了确认它们中断当前任务。只有用户主动查看作者画像、候选积累到适合回顾的节点,或新偏好与 active 条目冲突时,才集中呈现。

## 可靠性与负荷边界

- 明确“记住 / 确认 / 替换 / 忘掉”的请求走单事件 `record`,不要求 agent 手工读取修订号或拼多操作事务。成功响应会给出 `Author Memory Receipt: rN · APxxx`;没有回执就不得声称“已经记住”。
- 普通创作只做一次本地 `query`,没有 state 时返回空结果且不创建文件;有记忆时也只返回相关 active 条目,硬上限 2048 字节。完整画像、证据、候选和 journal 不进入正文 prompt。
- 查询项是低优先级倾向,不是逐条打卡清单。自然吸收即可,不复述画像、不刻意提高词面命中率,也不得为命中偏好牺牲正文连贯、节奏、字数或本书既定笔调。
- 不安装会记录全部用户消息的 prompt hook。自然语言是否属于长期习惯仍需 agent 判断;这样不能承诺隐式偏好 100% 捕获,但避免把一次性要求、私人对话和小说事实静默写入长期记忆。需要确定写入时,用户可明确说“记住:……”,并以回执验收。

## 捕获判定

| 输入证据 | 处理 |
|---|---|
| “以后都这样”“我一直习惯……”等直接、稳定、范围清楚的原话 | `active`,`source=explicit_user` |
| 用户明确接受助手提出的长期做法 | `active`,`source=accepted_suggestion` |
| 同类修改反复出现,但用户没说这是长期规则 | `pending`,`source=repeated_correction` |
| 从成稿或操作轨迹推断出的模式 | `pending`,`source=inferred_pattern` |
| “这一章别……”“这次给我……”等一次性要求 | 只执行,不记录 |
| 角色、时间线、伏笔、世界观、当前剧情走向 | 写项目设定/追踪,不写作者记忆 |
| 助手自己生成的文字、默认模板、工具告警、rubric 结论 | 不自我学习 |

保留用户的否定词、限定词和适用范围,`quote` 写原话,`assertion` 只做不改变语义的紧凑归纳。范围规则:

- “本书 / 这个角色 / 这次连载” → `book`;
- “都市文 / 这类题材” → `genre`;
- 交稿、检查、确认节奏等操作习惯 → `workflow`;
- “以后 / 一贯 / 我习惯”且无更窄限定 → `global`;
- 范围含糊但可能稳定 → 取当前最窄合理范围并置 `pending`。

类型可选:`prose_style`、`story_design`、`workflow`、`delivery`、`interaction`。置信度与重要度均为 `low | medium | high`。

## 冲突、撤回与强化

- 同一类型、范围、归纳文本再次出现时,脚本强化原条目,累加证据和确认次数,不重复建条目。
- 新偏好与 active 条目矛盾时,先以 `conflict` 记候选,并在 `conflicts_with` 列出冲突 ID;当前任务仍按本轮明确要求执行。
- 作者选定新规则时用 `replace`,一次性启用新条目并把旧条目标成 `superseded`。
- pending 可以用 `decide=activate|reject`;冲突候选不能绕过旧规则直接 activate。
- 作者说“忘掉 / 这不再是我的习惯”时用 `forget`,保留历史证据但不再加载。
- active 条目的语义不可原地偷改;语义变化必须 replace,历史才可审计。

## 运行工具

先依次尝试 `python3`、`python`、`py -3` 找到 Python 3,再从当前 skill 根运行本地副本:

```text
{PYTHON} {当前 skill 根}/scripts/author_memory_commit.py init   --workspace {工作区}
{PYTHON} {当前 skill 根}/scripts/author_memory_commit.py record --workspace {工作区} --input {单事件.json}
{PYTHON} {当前 skill 根}/scripts/author_memory_commit.py query  --workspace {工作区} [--kind prose_style] [--book {书名}] [--genre {题材}] [--workflow {流程}]
{PYTHON} {当前 skill 根}/scripts/author_memory_commit.py commit --workspace {工作区} --input {事务.json}
{PYTHON} {当前 skill 根}/scripts/author_memory_commit.py check  --workspace {工作区}
```

- `record`:常用单事件入口,自动读取当前修订、首次自动初始化;`event_id` 相同且内容相同会幂等返回原回执,内容不同会失败。
- `query`:只读相关 active 条目;`--kind` 可重复,不存在 state 时返回空结果且零写入。返回的 `omitted > 0` 时收窄 kind / book / genre / workflow 后重查,不得改读完整画像规避预算。
- `commit`:高级批量入口;先在内存完成 schema、引用、容量和所有视图校验,最后原子替换 state。事务文件在成功前必须保留;过期修订会在任何写入前失败。
- `check`:从 state 重建并逐字核验所有派生视图。

## 事务格式

常用单事件新增或强化:

```json
{
  "schema_version": 1,
  "event_id": "conversation-2026-08-25-message-42",
  "operation": {
    "action": "remember",
    "preference": {
      "kind": "prose_style",
      "scope": {"level": "global", "value": null},
      "assertion": "对话尽量短,用动作承接情绪,不用大段解释",
        "quote": "以后对话都短一点,情绪放动作里,别让角色长篇解释。",
        "source_ref": "conversation:2026-08-25",
        "source": "explicit_user",
        "confidence": "high",
        "importance": "high",
      "status": "active",
      "reason": "用户以“以后”明确声明长期偏好",
      "conflicts_with": []
    }
  }
}
```

把文件交给 `record`。待确认项的 `status` 用 `pending`;冲突候选用 `conflict` 并填写 active ID。确认或拒绝候选时,把下列对象作为新事件的 `operation`:

```json
{"action":"decide","item_id":"AP002","decision":"activate","quote":"对,这就是我的长期习惯。","reason":"作者明确确认"}
```

用新规则替代一个或多个旧条目时,`replace.preference` 与上例字段相同,但不传 `status`、`conflicts_with`,新条目直接 active;下列对象同样作为 `operation`:

```json
{
  "action": "replace",
  "old_ids": ["AP001", "AP002"],
  "preference": {
    "kind": "prose_style",
    "scope": {"level": "book", "value": "雾港来信"},
    "assertion": "本书对话允许更长的试探,但避免解释设定",
    "quote": "这本书可以让对话慢一点,多试探,但还是别拿台词讲设定。",
    "source_ref": "conversation:2026-08-25",
    "source": "explicit_user",
    "confidence": "high",
    "importance": "high",
    "reason": "作者明确用本书新规则替代旧候选"
  }
}
```

撤回条目的 `operation`:

```json
{"action":"forget","item_id":"AP003","quote":"忘掉这个偏好。","reason":"作者明确撤回"}
```

需要把多个动作绑定成一次原子提交时才用高级 `commit`:顶层传 `schema_version`、唯一 `transaction_id`、当前 `expected_state_revision` 和含 1–32 项的 `operations`。操作按数组顺序应用,任一步失败则整份事务零写入。成功后删除临时输入文件;显式记忆请求还要把工具返回的回执原样告诉用户。
references/banned-words.md
# AI味禁用词与句式表

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

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

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

| 毒级 | 句式 | 错误例 | 修法 |
|------|------|--------|------|
| ★★★★★ | "不是A,(而)是B" / "不是A,不是B,(而)是C"("而"可省略,省掉也算命中)| "他不是冷漠,而是绝望" | 直接写 B 或用更自然的表达 |
| ★★★☆☆ | 跨段「不是A。/也不是B。/只是C。」 | 「不是嚎啕大哭。/也不是扯着嗓子喊不舍。/只是一个人走远了……」 | 语义复核;重复提纲或拖慢画面时压成 C,有辩解/悬念排除功能可保留 |
| ★★★★ | ",带着……" 万能状语 | "他笑了一下,带着一丝不易察觉的嘲讽" | 删掉状语留主句,或换具体动作 |
| ★★★★ | 无情绪声线:"声音不大,却带着……" / "语气毫无波澜" / "平静无波" / "声音平直/平平/听不出情绪" | "她声音不大,却带着不容置疑的力量" | 直接写台词内容、声音特征或动作 |
| ★★★★ | "他/她知道……" | "他知道这一切都来不及了" | 用行为展示认知 |
| ★★★ | "仿佛/犹如/宛若……一般" | "仿佛能穿透一切一般" | 删掉或白描 |
| ★★★ | "眼中闪过一丝……" / "嘴角勾起一抹……" | "眼中闪过一丝悲伤" | 删掉;写他当场说的话或做出的决定 |
| ★★★ | "心中涌起一股……" / "心头一震" | "心中涌起一股暖流" | 写它改变了什么:选择、台词、物件或后果 |
| ★★★ | 抽象命运/开端收束:"命运……棋局/獠牙" / "这一刻终于明白" / "反击才刚刚开始" | "命运终于露出獠牙;属于他的反击才刚刚开始" | 回到角色当下可见的文件、动作、对话或物理后果 |
| ★★ | 章末预告 "他不知道的是……" | "他不知道的是,更大的风暴即将来临" | 用具体钩子物件/事件收束,避免空泛预告 |

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

`check-ai-patterns.js` 的 `formulaic-parallelism` 还会提示「至于X不X,怎么X」和同动词「不V A,不V B」。这两类可能是功能性口语,因此只做 advisory;Gate B 必须连同台词读语境复核,若只是复述细纲/前文就压成一次判断,不能因 hook 豁免台词而跳过。

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

---

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

> 什么词进一级:只收真人语料里几乎不出现、AI 特有的词。真人高频使用的自然副词和虚词不进一级,走二级密度控制。

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

### 动作类
深吸一口气、不禁

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

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

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

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

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

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

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

### 弱化副词(密度控制)
缓缓、微微、轻轻、淡淡(每千字合计 ≤3;这四个词同时计入 `cliche-density-tic` 的套词密度统计;孤立自然使用可保留,成串出现或每个动作都垫一个时才替换)

### 书面腔 → 口语化

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

### 总结句式
- "他/她终于明白..."
- "他/她这才意识到..."
- "这一刻,他/她终于明白/意识到..."
- "从这一刻开始..."
- "属于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` 时,说明禁用词不是零星误用,而是聚成了模板腔。处理顺序不是同义词替换,而是先删抽象总结,再把情绪/判断落到角色当下可见的动作、物件、对话和具体后果。

**套式反应逐处删除测试**:`stock-reaction-tic` 报警时,不代表禁止身体描写。逐处问:删掉后信息、选择、关系、物件或动作结果是否受损?无损就删,不把“指尖轻叩”换成“目光微沉”。伤势、动作失败、人物习惯或情节后果明确时可以保留。
scripts/author_memory_commit.py
#!/usr/bin/env python3
"""Maintain evidence-backed author preferences and deterministic Markdown views.

The language model supplies compact semantic transactions. This tool validates
and applies them in memory, renders every derived view, and writes the JSON state
last as the commit point. Author memory is workspace-level and deliberately
separate from each book's story-continuity tracking.
"""

from __future__ import annotations

import argparse
import copy
import hashlib
import json
import os
import stat
import sys
import tempfile
from datetime import datetime, timezone
from pathlib import Path
from typing import Any


INPUT_SCHEMA_VERSION = 1
STATE_SCHEMA_VERSION = 1
STATE_MAX_BYTES = 2 * 1024 * 1024
PROFILE_MAX_BYTES = 12288
PENDING_MAX_BYTES = 12288
JOURNAL_MAX_BYTES = 24576
QUERY_MAX_BYTES = 2048

KINDS = ("prose_style", "story_design", "workflow", "delivery", "interaction")
KIND_TITLES = {
    "prose_style": "文风与表达",
    "story_design": "故事设计",
    "workflow": "创作流程",
    "delivery": "交付格式",
    "interaction": "协作方式",
}
SCOPE_LEVELS = ("global", "genre", "book", "workflow")
STATUSES = ("active", "pending", "conflict", "rejected", "superseded")
CONFIDENCE_LEVELS = ("low", "medium", "high")
IMPORTANCE_LEVELS = ("low", "medium", "high")
SOURCES = (
    "explicit_user",
    "accepted_suggestion",
    "repeated_correction",
    "inferred_pattern",
    "manual",
)
RANK = {"low": 0, "medium": 1, "high": 2}


class AuthorMemoryError(ValueError):
    """Expected validation or state error."""


def require(condition: bool, message: str) -> None:
    if not condition:
        raise AuthorMemoryError(message)


def as_mapping(value: object, label: str) -> dict[str, Any]:
    require(isinstance(value, dict), f"{label} must be a JSON object")
    return value


def as_list(value: object, label: str) -> list[Any]:
    require(isinstance(value, list), f"{label} must be a JSON array")
    return value


def as_int(value: object, label: str, *, minimum: int = 0) -> int:
    require(isinstance(value, int) and not isinstance(value, bool), f"{label} must be an integer")
    require(value >= minimum, f"{label} must be >= {minimum}")
    return value


def require_known_keys(mapping: dict[str, Any], allowed: set[str], label: str) -> None:
    unknown = set(mapping) - allowed
    require(not unknown, f"{label} contains unsupported fields: {', '.join(sorted(unknown))}")


def clean_text(value: object, label: str, *, max_bytes: int = 768) -> str:
    require(isinstance(value, str), f"{label} must be a string")
    cleaned = " ".join(value.replace("|", "|").split())
    require(bool(cleaned), f"{label} must not be empty")
    require(len(cleaned.encode("utf-8")) <= max_bytes, f"{label} exceeds {max_bytes} bytes")
    return cleaned


def optional_text(value: object, label: str, *, max_bytes: int = 768) -> str | None:
    if value is None:
        return None
    return clean_text(value, label, max_bytes=max_bytes)


def choice(value: object, allowed: tuple[str, ...], label: str) -> str:
    require(isinstance(value, str) and value in allowed, f"{label} must be one of: {', '.join(allowed)}")
    return value


def clean_id_list(value: object, label: str, *, maximum: int = 32) -> list[str]:
    raw = as_list(value, label)
    require(len(raw) <= maximum, f"{label} may contain at most {maximum} items")
    result: list[str] = []
    for index, item in enumerate(raw):
        item_id = clean_text(item, f"{label}[{index}]", max_bytes=32)
        require(item_id.startswith("AP") and item_id[2:].isdigit() and int(item_id[2:]) >= 1, f"{label}[{index}] is not an author-memory id")
        if item_id not in result:
            result.append(item_id)
    return result


def emit(document: object, *, error: bool = False) -> None:
    payload = json.dumps(document, ensure_ascii=False, sort_keys=True)
    stream = sys.stderr if error else sys.stdout
    stream.flush()
    stream.buffer.write((payload + "\n").encode("utf-8"))
    stream.buffer.flush()


def json_payload(document: object) -> str:
    return json.dumps(document, ensure_ascii=False, indent=2, sort_keys=True) + "\n"


def read_json(path: Path) -> object:
    try:
        require(path.stat().st_size <= STATE_MAX_BYTES, f"{path} exceeds {STATE_MAX_BYTES} bytes")
        return json.loads(path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError) as exc:
        raise AuthorMemoryError(f"unable to read JSON {path}: {exc}") from exc


def atomic_write_text(path: Path, payload: str) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    mode = stat.S_IMODE(path.stat().st_mode) if path.exists() else 0o644
    fd, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
    temporary = Path(temporary_name)
    try:
        with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
            handle.write(payload)
            handle.flush()
            os.fsync(handle.fileno())
        os.chmod(temporary, mode)
        os.replace(temporary, path)
    finally:
        temporary.unlink(missing_ok=True)


def write_if_changed(path: Path, payload: str) -> None:
    try:
        if path.read_text(encoding="utf-8") == payload:
            return
    except FileNotFoundError:
        pass
    atomic_write_text(path, payload)


def memory_root(workspace: Path) -> Path:
    return workspace.resolve() / ".story" / "作者记忆"


def state_path(workspace: Path) -> Path:
    return memory_root(workspace) / "_author-memory-state.json"


def empty_state() -> dict[str, Any]:
    return {
        "schema_version": STATE_SCHEMA_VERSION,
        "state_revision": 0,
        "next_item_number": 1,
        "items": {},
        "journal": [],
        "applied_transactions": {},
    }


def normalize_scope(value: object, label: str) -> dict[str, str | None]:
    scope = as_mapping(value, label)
    require_known_keys(scope, {"level", "value"}, label)
    level = choice(scope.get("level"), SCOPE_LEVELS, f"{label}.level")
    raw_value = scope.get("value")
    if level == "global":
        require(raw_value is None, f"{label}.value must be null for global scope")
        normalized_value = None
    else:
        normalized_value = clean_text(raw_value, f"{label}.value", max_bytes=180)
    return {"level": level, "value": normalized_value}


def normalize_evidence(value: object, label: str) -> dict[str, str | None]:
    evidence = as_mapping(value, label)
    require_known_keys(evidence, {"quote", "source_ref"}, label)
    return {
        "quote": clean_text(evidence.get("quote"), f"{label}.quote", max_bytes=768),
        "source_ref": optional_text(evidence.get("source_ref"), f"{label}.source_ref", max_bytes=240),
    }


def normalize_item(value: object, label: str) -> dict[str, Any]:
    item = as_mapping(value, label)
    allowed = {
        "id", "kind", "scope", "assertion", "confidence", "importance", "status", "source",
        "reason", "conflicts_with", "confirmation_count", "evidence", "created_revision",
        "updated_revision", "superseded_by",
    }
    require_known_keys(item, allowed, label)
    item_id = clean_text(item.get("id"), f"{label}.id", max_bytes=32)
    require(item_id.startswith("AP") and item_id[2:].isdigit() and int(item_id[2:]) >= 1, f"{label}.id is invalid")
    evidence = [normalize_evidence(entry, f"{label}.evidence[{index}]") for index, entry in enumerate(as_list(item.get("evidence"), f"{label}.evidence"))]
    require(bool(evidence), f"{label}.evidence must not be empty")
    status = choice(item.get("status"), STATUSES, f"{label}.status")
    conflicts = clean_id_list(item.get("conflicts_with"), f"{label}.conflicts_with")
    superseded_by = optional_text(item.get("superseded_by"), f"{label}.superseded_by", max_bytes=32)
    if superseded_by is not None:
        require(superseded_by.startswith("AP") and superseded_by[2:].isdigit(), f"{label}.superseded_by is invalid")
    return {
        "id": item_id,
        "kind": choice(item.get("kind"), KINDS, f"{label}.kind"),
        "scope": normalize_scope(item.get("scope"), f"{label}.scope"),
        "assertion": clean_text(item.get("assertion"), f"{label}.assertion", max_bytes=768),
        "confidence": choice(item.get("confidence"), CONFIDENCE_LEVELS, f"{label}.confidence"),
        "importance": choice(item.get("importance"), IMPORTANCE_LEVELS, f"{label}.importance"),
        "status": status,
        "source": choice(item.get("source"), SOURCES, f"{label}.source"),
        "reason": clean_text(item.get("reason"), f"{label}.reason", max_bytes=480),
        "conflicts_with": conflicts,
        "confirmation_count": as_int(item.get("confirmation_count"), f"{label}.confirmation_count", minimum=1),
        "evidence": evidence,
        "created_revision": as_int(item.get("created_revision"), f"{label}.created_revision", minimum=1),
        "updated_revision": as_int(item.get("updated_revision"), f"{label}.updated_revision", minimum=1),
        "superseded_by": superseded_by,
    }


def validate_state(value: object) -> dict[str, Any]:
    state = as_mapping(value, "state")
    allowed = {"schema_version", "state_revision", "next_item_number", "items", "journal", "applied_transactions"}
    require_known_keys(state, allowed, "state")
    require(state.get("schema_version") == STATE_SCHEMA_VERSION, f"state.schema_version must be {STATE_SCHEMA_VERSION}")
    revision = as_int(state.get("state_revision"), "state.state_revision")
    next_number = as_int(state.get("next_item_number"), "state.next_item_number", minimum=1)
    raw_items = as_mapping(state.get("items"), "state.items")
    items: dict[str, Any] = {}
    max_number = 0
    for raw_id, raw_item in raw_items.items():
        normalized = normalize_item(raw_item, f"state.items.{raw_id}")
        require(raw_id == normalized["id"], f"state.items key {raw_id} does not match item id")
        max_number = max(max_number, int(raw_id[2:]))
        require(normalized["created_revision"] <= normalized["updated_revision"] <= revision, f"state.items.{raw_id} revision is ahead of state")
        items[raw_id] = normalized
    require(next_number > max_number, "state.next_item_number must be greater than every allocated item id")
    for item_id, item in items.items():
        for conflict_id in item["conflicts_with"]:
            require(conflict_id in items and conflict_id != item_id, f"state.items.{item_id} has an invalid conflict id")
        if item["superseded_by"] is not None:
            require(item["superseded_by"] in items and item["superseded_by"] != item_id, f"state.items.{item_id} has an invalid superseded_by id")
        if item["status"] == "active":
            require(not item["conflicts_with"], f"active item {item_id} cannot retain conflicts")
        if item["status"] == "pending":
            require(not item["conflicts_with"], f"pending item {item_id} cannot retain conflicts")
        if item["status"] == "conflict":
            require(bool(item["conflicts_with"]), f"conflict item {item_id} must reference an active item")
            require(all(items[conflict_id]["status"] == "active" for conflict_id in item["conflicts_with"]), f"conflict item {item_id} must reference only active items")
        if item["status"] != "superseded":
            require(item["superseded_by"] is None, f"only superseded item {item_id} may set superseded_by")
    journal = as_list(state.get("journal"), "state.journal")
    require(len(journal) == revision, "state.journal length must equal state.state_revision")
    journal_revisions: dict[str, int] = {}
    for index, entry in enumerate(journal):
        mapping = as_mapping(entry, f"state.journal[{index}]")
        require_known_keys(mapping, {"revision", "transaction_id", "committed_at", "summaries"}, f"state.journal[{index}]")
        entry_revision = as_int(mapping.get("revision"), f"state.journal[{index}].revision", minimum=1)
        require(entry_revision == index + 1, f"state.journal[{index}].revision must be {index + 1}")
        transaction_id = clean_text(mapping.get("transaction_id"), f"state.journal[{index}].transaction_id", max_bytes=128)
        require(transaction_id not in journal_revisions, f"state.journal repeats transaction_id {transaction_id}")
        journal_revisions[transaction_id] = entry_revision
        clean_text(mapping.get("committed_at"), f"state.journal[{index}].committed_at", max_bytes=64)
        summaries = as_list(mapping.get("summaries"), f"state.journal[{index}].summaries")
        require(bool(summaries), f"state.journal[{index}].summaries must not be empty")
        for summary_index, summary in enumerate(summaries):
            clean_text(summary, f"state.journal[{index}].summaries[{summary_index}]", max_bytes=768)
    transactions = as_mapping(state.get("applied_transactions"), "state.applied_transactions")
    require(set(transactions) == set(journal_revisions), "state.applied_transactions must match state.journal transaction ids")
    for transaction_id, record in transactions.items():
        clean_text(transaction_id, "state.applied_transactions key", max_bytes=128)
        mapping = as_mapping(record, f"state.applied_transactions.{transaction_id}")
        require_known_keys(mapping, {"revision", "digest", "item_ids"}, f"state.applied_transactions.{transaction_id}")
        transaction_revision = as_int(mapping.get("revision"), f"state.applied_transactions.{transaction_id}.revision", minimum=1)
        require(transaction_revision == journal_revisions[transaction_id], f"state.applied_transactions.{transaction_id}.revision does not match journal")
        digest = clean_text(mapping.get("digest"), f"state.applied_transactions.{transaction_id}.digest", max_bytes=64)
        require(len(digest) == 64 and all(char in "0123456789abcdef" for char in digest), f"state.applied_transactions.{transaction_id}.digest is invalid")
        item_ids = clean_id_list(mapping.get("item_ids"), f"state.applied_transactions.{transaction_id}.item_ids")
        require(bool(item_ids), f"state.applied_transactions.{transaction_id}.item_ids must not be empty")
        require(all(item_id in items for item_id in item_ids), f"state.applied_transactions.{transaction_id}.item_ids references an unknown item")
    return {
        "schema_version": STATE_SCHEMA_VERSION,
        "state_revision": revision,
        "next_item_number": next_number,
        "items": items,
        "journal": copy.deepcopy(journal),
        "applied_transactions": copy.deepcopy(transactions),
    }


def normalize_preference(value: object, label: str, *, allow_status: bool) -> dict[str, Any]:
    preference = as_mapping(value, label)
    allowed = {"kind", "scope", "assertion", "quote", "source_ref", "source", "confidence", "importance", "reason"}
    if allow_status:
        allowed |= {"status", "conflicts_with"}
    require_known_keys(preference, allowed, label)
    source = choice(preference.get("source"), SOURCES, f"{label}.source")
    status = choice(preference.get("status"), ("active", "pending", "conflict"), f"{label}.status") if allow_status else "active"
    conflicts = clean_id_list(preference.get("conflicts_with", []), f"{label}.conflicts_with") if allow_status else []
    if status == "active":
        require(not conflicts, f"{label}.conflicts_with must be empty for active status")
        require(source not in {"repeated_correction", "inferred_pattern"}, f"{label} inferred evidence must remain pending")
    elif status == "conflict":
        require(bool(conflicts), f"{label}.conflicts_with is required for conflict status")
    else:
        require(not conflicts, f"{label}.conflicts_with is only valid for conflict status")
    return {
        "kind": choice(preference.get("kind"), KINDS, f"{label}.kind"),
        "scope": normalize_scope(preference.get("scope"), f"{label}.scope"),
        "assertion": clean_text(preference.get("assertion"), f"{label}.assertion", max_bytes=768),
        "quote": clean_text(preference.get("quote"), f"{label}.quote", max_bytes=768),
        "source_ref": optional_text(preference.get("source_ref"), f"{label}.source_ref", max_bytes=240),
        "source": source,
        "confidence": choice(preference.get("confidence"), CONFIDENCE_LEVELS, f"{label}.confidence"),
        "importance": choice(preference.get("importance"), IMPORTANCE_LEVELS, f"{label}.importance"),
        "status": status,
        "reason": clean_text(preference.get("reason"), f"{label}.reason", max_bytes=480),
        "conflicts_with": conflicts,
    }


def normalize_transaction(value: object) -> dict[str, Any]:
    transaction = as_mapping(value, "transaction")
    require_known_keys(transaction, {"schema_version", "transaction_id", "expected_state_revision", "operations"}, "transaction")
    require(transaction.get("schema_version") == INPUT_SCHEMA_VERSION, f"transaction.schema_version must be {INPUT_SCHEMA_VERSION}")
    transaction_id = clean_text(transaction.get("transaction_id"), "transaction.transaction_id", max_bytes=128)
    operations = as_list(transaction.get("operations"), "transaction.operations")
    require(1 <= len(operations) <= 32, "transaction.operations must contain 1-32 operations")
    normalized_operations: list[dict[str, Any]] = []
    for index, raw_operation in enumerate(operations):
        label = f"transaction.operations[{index}]"
        operation = as_mapping(raw_operation, label)
        action = operation.get("action")
        if action == "remember":
            require_known_keys(operation, {"action", "preference"}, label)
            normalized_operations.append({"action": action, "preference": normalize_preference(operation.get("preference"), f"{label}.preference", allow_status=True)})
        elif action == "decide":
            require_known_keys(operation, {"action", "item_id", "decision", "quote", "reason"}, label)
            normalized_operations.append({
                "action": action,
                "item_id": clean_id_list([operation.get("item_id")], f"{label}.item_id", maximum=1)[0],
                "decision": choice(operation.get("decision"), ("activate", "reject"), f"{label}.decision"),
                "quote": clean_text(operation.get("quote"), f"{label}.quote", max_bytes=768),
                "reason": clean_text(operation.get("reason"), f"{label}.reason", max_bytes=480),
            })
        elif action == "replace":
            require_known_keys(operation, {"action", "old_ids", "preference"}, label)
            old_ids = clean_id_list(operation.get("old_ids"), f"{label}.old_ids")
            require(bool(old_ids), f"{label}.old_ids must not be empty")
            normalized_operations.append({"action": action, "old_ids": old_ids, "preference": normalize_preference(operation.get("preference"), f"{label}.preference", allow_status=False)})
        elif action == "forget":
            require_known_keys(operation, {"action", "item_id", "quote", "reason"}, label)
            normalized_operations.append({
                "action": action,
                "item_id": clean_id_list([operation.get("item_id")], f"{label}.item_id", maximum=1)[0],
                "quote": clean_text(operation.get("quote"), f"{label}.quote", max_bytes=768),
                "reason": clean_text(operation.get("reason"), f"{label}.reason", max_bytes=480),
            })
        else:
            raise AuthorMemoryError(f"{label}.action must be one of: remember, decide, replace, forget")
    return {
        "schema_version": INPUT_SCHEMA_VERSION,
        "transaction_id": transaction_id,
        "expected_state_revision": as_int(transaction.get("expected_state_revision"), "transaction.expected_state_revision"),
        "operations": normalized_operations,
    }


def normalize_record_event(value: object) -> dict[str, Any]:
    event = as_mapping(value, "event")
    require_known_keys(event, {"schema_version", "event_id", "operation"}, "event")
    require(event.get("schema_version") == INPUT_SCHEMA_VERSION, f"event.schema_version must be {INPUT_SCHEMA_VERSION}")
    event_id = clean_text(event.get("event_id"), "event.event_id", max_bytes=120)
    normalized = normalize_transaction({
        "schema_version": INPUT_SCHEMA_VERSION,
        "transaction_id": f"record:{event_id}",
        "expected_state_revision": 0,
        "operations": [event.get("operation")],
    })
    return {"event_id": event_id, "operation": normalized["operations"][0]}


def transaction_digest(transaction: dict[str, Any]) -> str:
    canonical = json.dumps(transaction, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
    return hashlib.sha256(canonical.encode("utf-8")).hexdigest()


def fingerprint(preference: dict[str, Any]) -> str:
    value = {
        "kind": preference["kind"],
        "scope": preference["scope"],
        "assertion": preference["assertion"].casefold(),
    }
    return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))


def allocate_item(state: dict[str, Any], preference: dict[str, Any], revision: int) -> dict[str, Any]:
    item_id = f"AP{state['next_item_number']:03d}"
    state["next_item_number"] += 1
    return {
        "id": item_id,
        "kind": preference["kind"],
        "scope": copy.deepcopy(preference["scope"]),
        "assertion": preference["assertion"],
        "confidence": preference["confidence"],
        "importance": preference["importance"],
        "status": preference["status"],
        "source": preference["source"],
        "reason": preference["reason"],
        "conflicts_with": list(preference["conflicts_with"]),
        "confirmation_count": 1,
        "evidence": [{"quote": preference["quote"], "source_ref": preference["source_ref"]}],
        "created_revision": revision,
        "updated_revision": revision,
        "superseded_by": None,
    }


def best_level(first: str, second: str) -> str:
    return first if RANK[first] >= RANK[second] else second


def add_evidence(item: dict[str, Any], quote: str, source_ref: str | None) -> None:
    evidence = {"quote": quote, "source_ref": source_ref}
    if evidence not in item["evidence"]:
        item["evidence"].append(evidence)


def require_item(state: dict[str, Any], item_id: str, label: str) -> dict[str, Any]:
    require(item_id in state["items"], f"{label} references unknown item {item_id}")
    return state["items"][item_id]


def apply_remember(state: dict[str, Any], preference: dict[str, Any], revision: int) -> str:
    for conflict_id in preference["conflicts_with"]:
        conflict = require_item(state, conflict_id, "remember")
        require(conflict["status"] == "active", f"remember conflict {conflict_id} must be active")
    preference_fingerprint = fingerprint(preference)
    for item in state["items"].values():
        if item["status"] not in {"active", "pending", "conflict"} or fingerprint(item) != preference_fingerprint:
            continue
        require(not (item["status"] == "conflict" and preference["status"] == "active"), f"conflict item {item['id']} must be resolved with replace or rejected")
        require(not (item["status"] == "active" and preference["status"] == "conflict"), f"active item {item['id']} cannot be recategorized as its own conflict")
        add_evidence(item, preference["quote"], preference["source_ref"])
        item["confirmation_count"] += 1
        item["confidence"] = best_level(item["confidence"], preference["confidence"])
        item["importance"] = best_level(item["importance"], preference["importance"])
        item["updated_revision"] = revision
        item["reason"] = preference["reason"]
        if item["status"] == "pending" and preference["status"] == "active":
            item["status"] = "active"
        elif item["status"] == "pending" and preference["status"] == "conflict":
            item["status"] = "conflict"
            item["conflicts_with"] = list(preference["conflicts_with"])
        elif item["status"] == "conflict" and preference["status"] == "conflict":
            item["conflicts_with"] = sorted(set(item["conflicts_with"]) | set(preference["conflicts_with"]))
        return f"强化 {item['id']}:{item['assertion']}"
    item = allocate_item(state, preference, revision)
    state["items"][item["id"]] = item
    return f"新增 {item['id']}({item['status']}):{item['assertion']}"


def apply_decide(state: dict[str, Any], operation: dict[str, Any], revision: int) -> str:
    item = require_item(state, operation["item_id"], "decide")
    require(item["status"] in {"pending", "conflict"}, f"decide requires pending/conflict item, got {item['status']}")
    if operation["decision"] == "activate":
        require(item["status"] == "pending" and not item["conflicts_with"], "conflict candidates must be activated with replace")
        item["status"] = "active"
        verb = "确认"
    else:
        item["status"] = "rejected"
        verb = "拒绝"
    add_evidence(item, operation["quote"], None)
    item["reason"] = operation["reason"]
    item["updated_revision"] = revision
    return f"{verb} {item['id']}:{item['assertion']}"


def apply_replace(state: dict[str, Any], operation: dict[str, Any], revision: int) -> str:
    old_items = [require_item(state, item_id, "replace") for item_id in operation["old_ids"]]
    for item in old_items:
        require(item["status"] in {"active", "conflict", "pending"}, f"replace target {item['id']} is already {item['status']}")
    replacement = allocate_item(state, operation["preference"], revision)
    replacement["status"] = "active"
    replacement["conflicts_with"] = []
    state["items"][replacement["id"]] = replacement
    for item in old_items:
        item["status"] = "superseded"
        item["superseded_by"] = replacement["id"]
        item["updated_revision"] = revision
    old_ids = {item["id"] for item in old_items}
    released = 0
    for candidate in state["items"].values():
        if candidate["status"] != "conflict":
            continue
        retained = [item_id for item_id in candidate["conflicts_with"] if item_id not in old_ids]
        if retained == candidate["conflicts_with"]:
            continue
        candidate["conflicts_with"] = retained
        candidate["updated_revision"] = revision
        if not retained:
            candidate["status"] = "pending"
            released += 1
    replaced = ", ".join(item["id"] for item in old_items)
    suffix = f";{released} 个其他冲突候选退回待确认" if released else ""
    return f"用 {replacement['id']} 替代 {replaced}:{replacement['assertion']}{suffix}"


def apply_forget(state: dict[str, Any], operation: dict[str, Any], revision: int) -> str:
    item = require_item(state, operation["item_id"], "forget")
    require(item["status"] in {"active", "pending", "conflict"}, f"forget target {item['id']} is already {item['status']}")
    item["status"] = "superseded"
    item["superseded_by"] = None
    item["reason"] = operation["reason"]
    item["updated_revision"] = revision
    add_evidence(item, operation["quote"], None)
    released = 0
    for candidate in state["items"].values():
        if candidate["status"] != "conflict" or item["id"] not in candidate["conflicts_with"]:
            continue
        candidate["conflicts_with"] = [conflict_id for conflict_id in candidate["conflicts_with"] if conflict_id != item["id"]]
        candidate["updated_revision"] = revision
        if not candidate["conflicts_with"]:
            candidate["status"] = "pending"
            released += 1
    suffix = f";{released} 个冲突候选退回待确认" if released else ""
    return f"忘记 {item['id']}:{item['assertion']}{suffix}"


def apply_transaction(state: dict[str, Any], transaction: dict[str, Any], digest: str) -> tuple[dict[str, Any], list[str]]:
    applied = state["applied_transactions"].get(transaction["transaction_id"])
    if applied is not None:
        require(applied["digest"] == digest, "transaction_id was already used with different content")
        return state, [f"事务已应用于修订 {applied['revision']},本次为幂等重放"]
    require(transaction["expected_state_revision"] == state["state_revision"], f"stale state revision: expected {transaction['expected_state_revision']}, current {state['state_revision']}")
    updated = copy.deepcopy(state)
    revision = updated["state_revision"] + 1
    summaries: list[str] = []
    for operation in transaction["operations"]:
        if operation["action"] == "remember":
            summaries.append(apply_remember(updated, operation["preference"], revision))
        elif operation["action"] == "decide":
            summaries.append(apply_decide(updated, operation, revision))
        elif operation["action"] == "replace":
            summaries.append(apply_replace(updated, operation, revision))
        else:
            summaries.append(apply_forget(updated, operation, revision))
    committed_at = datetime.now(timezone.utc).replace(microsecond=0).isoformat()
    updated["state_revision"] = revision
    updated["journal"].append({
        "revision": revision,
        "transaction_id": transaction["transaction_id"],
        "committed_at": committed_at,
        "summaries": summaries,
    })
    item_ids = sorted(
        (item_id for item_id, item in updated["items"].items() if item["updated_revision"] == revision),
        key=lambda item_id: int(item_id[2:]),
    )
    require(bool(item_ids), "transaction did not update any author-memory item")
    updated["applied_transactions"][transaction["transaction_id"]] = {
        "revision": revision,
        "digest": digest,
        "item_ids": item_ids,
    }
    return validate_state(updated), summaries


def scope_label(scope: dict[str, str | None]) -> str:
    if scope["level"] == "global":
        return "全局"
    labels = {"genre": "题材", "book": "本书", "workflow": "流程"}
    return f"{labels[scope['level']]}:{scope['value']}"


def render_profile(state: dict[str, Any]) -> str:
    lines = [
        "# 作者画像",
        "",
        "<!-- 由 author_memory_commit.py 生成,请勿手改;修改请提交事务。 -->",
        "",
        f"> 状态修订:{state['state_revision']}。仅列出已确认偏好;当前明确要求、本书设定与硬性门禁优先。",
        "",
    ]
    active = [item for item in state["items"].values() if item["status"] == "active"]
    for kind in KINDS:
        lines.extend([f"## {KIND_TITLES[kind]}", ""])
        items = sorted((item for item in active if item["kind"] == kind), key=lambda item: int(item["id"][2:]))
        if not items:
            lines.extend(["- 暂无", ""])
            continue
        for item in items:
            lines.append(f"- **{item['id']}**〔{scope_label(item['scope'])}|{item['confidence']}|确认 {item['confirmation_count']} 次〕{item['assertion']}")
        lines.append("")
    return "\n".join(lines).rstrip() + "\n"


def render_pending(state: dict[str, Any]) -> str:
    lines = [
        "# 待确认的作者习惯",
        "",
        "<!-- 由 author_memory_commit.py 生成,请勿手改;修改请提交事务。 -->",
        "",
        f"> 状态修订:{state['state_revision']}。待确认项不参与创作约束,也不应打断当前任务。",
        "",
    ]
    items = sorted((item for item in state["items"].values() if item["status"] in {"pending", "conflict"}), key=lambda item: int(item["id"][2:]))
    if not items:
        lines.extend(["暂无待确认项。", ""])
    for item in items:
        lines.extend([
            f"## {item['id']} · {'冲突' if item['status'] == 'conflict' else '待确认'}",
            "",
            f"- 候选习惯:{item['assertion']}",
            f"- 范围:{scope_label(item['scope'])}",
            f"- 原话:\u201c{item['evidence'][-1]['quote']}\u201d",
            f"- 依据:{item['reason']}",
            f"- 置信度 / 重要度:{item['confidence']} / {item['importance']}",
        ])
        if item["conflicts_with"]:
            lines.append(f"- 冲突对象:{', '.join(item['conflicts_with'])}")
        lines.append("")
    return "\n".join(lines).rstrip() + "\n"


def render_journal(state: dict[str, Any]) -> str:
    lines = [
        "# 作者记忆变更记录",
        "",
        "<!-- 由 author_memory_commit.py 生成,请勿手改;最近记录在前。 -->",
        "",
    ]
    if not state["journal"]:
        lines.extend(["暂无变更。", ""])
    for entry in reversed(state["journal"][-100:]):
        lines.extend([f"## r{entry['revision']} · {entry['committed_at']}", "", f"- 事务:`{entry['transaction_id']}`"])
        lines.extend(f"- {summary}" for summary in entry["summaries"])
        lines.append("")
    return "\n".join(lines).rstrip() + "\n"


def render_views(state: dict[str, Any]) -> dict[str, str]:
    views = {
        "作者画像.md": render_profile(state),
        "待确认.md": render_pending(state),
        "变更记录.md": render_journal(state),
    }
    limits = {"作者画像.md": PROFILE_MAX_BYTES, "待确认.md": PENDING_MAX_BYTES, "变更记录.md": JOURNAL_MAX_BYTES}
    for name, payload in views.items():
        require(len(payload.encode("utf-8")) <= limits[name], f"{name} exceeds {limits[name]} bytes; consolidate old memory first")
    return views


def write_snapshot(workspace: Path, state: dict[str, Any]) -> None:
    root = memory_root(workspace)
    views = render_views(state)
    state_payload = json_payload(state)
    require(len(state_payload.encode("utf-8")) <= STATE_MAX_BYTES, f"_author-memory-state.json exceeds {STATE_MAX_BYTES} bytes")
    for name, payload in views.items():
        write_if_changed(root / name, payload)
    # State is the authority and therefore the last commit point.
    write_if_changed(state_path(workspace), state_payload)


def command_init(workspace: Path) -> dict[str, Any]:
    require(workspace.exists() and workspace.is_dir(), f"workspace does not exist: {workspace}")
    path = state_path(workspace)
    if path.exists():
        state = validate_state(read_json(path))
    else:
        state = empty_state()
    write_snapshot(workspace, state)
    return {"ok": True, "command": "init", "revision": state["state_revision"], "root": str(memory_root(workspace))}


def command_commit(workspace: Path, input_path: Path) -> dict[str, Any]:
    require(state_path(workspace).exists(), "author memory is not initialized; run init first")
    state = validate_state(read_json(state_path(workspace)))
    transaction = normalize_transaction(read_json(input_path))
    digest = transaction_digest(transaction)
    updated, summaries = apply_transaction(state, transaction, digest)
    replayed = updated is state
    if not replayed:
        write_snapshot(workspace, updated)
    else:
        # Repair missing or stale views during an idempotent retry.
        write_snapshot(workspace, state)
    return {
        "ok": True,
        "command": "commit",
        "revision": updated["state_revision"],
        "transaction_id": transaction["transaction_id"],
        "replayed": replayed,
        "item_ids": updated["applied_transactions"][transaction["transaction_id"]]["item_ids"],
        "summaries": summaries,
    }


def command_record(workspace: Path, input_path: Path) -> dict[str, Any]:
    require(workspace.exists() and workspace.is_dir(), f"workspace does not exist: {workspace}")
    event = normalize_record_event(read_json(input_path))
    path = state_path(workspace)
    state = validate_state(read_json(path)) if path.exists() else empty_state()
    transaction_id = f"record:{event['event_id']}"
    applied = state["applied_transactions"].get(transaction_id)
    expected_revision = applied["revision"] - 1 if applied is not None else state["state_revision"]
    transaction = {
        "schema_version": INPUT_SCHEMA_VERSION,
        "transaction_id": transaction_id,
        "expected_state_revision": expected_revision,
        "operations": [event["operation"]],
    }
    digest = transaction_digest(transaction)
    updated, summaries = apply_transaction(state, transaction, digest)
    replayed = updated is state
    write_snapshot(workspace, updated)
    record = updated["applied_transactions"][transaction_id]
    item_ids = record["item_ids"]
    receipt = f"Author Memory Receipt: r{record['revision']} · {', '.join(item_ids)}"
    return {
        "ok": True,
        "command": "record",
        "revision": updated["state_revision"],
        "applied_revision": record["revision"],
        "event_id": event["event_id"],
        "replayed": replayed,
        "item_ids": item_ids,
        "receipt": receipt,
        "summaries": summaries,
    }


def same_scope_value(item_value: str | None, requested: str | None) -> bool:
    return requested is not None and item_value is not None and item_value.casefold() == requested.casefold()


def command_query(
    workspace: Path,
    kinds: list[str] | None,
    book: str | None,
    genre: str | None,
    workflow: str | None,
) -> dict[str, Any]:
    require(workspace.exists() and workspace.is_dir(), f"workspace does not exist: {workspace}")
    path = state_path(workspace)
    if not path.exists():
        return {"ok": True, "command": "query", "initialized": False, "revision": 0, "items": [], "omitted": 0}
    state = validate_state(read_json(path))
    requested_kinds = set(kinds or KINDS)
    requested_scopes = {
        "book": optional_text(book, "query.book", max_bytes=180),
        "genre": optional_text(genre, "query.genre", max_bytes=180),
        "workflow": optional_text(workflow, "query.workflow", max_bytes=180),
    }

    def relevant(item: dict[str, Any]) -> bool:
        if item["status"] != "active" or item["kind"] not in requested_kinds:
            return False
        level = item["scope"]["level"]
        return level == "global" or same_scope_value(item["scope"]["value"], requested_scopes[level])

    scope_rank = {"book": 0, "genre": 1, "workflow": 2, "global": 3}
    candidates = sorted(
        (item for item in state["items"].values() if relevant(item)),
        key=lambda item: (
            scope_rank[item["scope"]["level"]],
            -RANK[item["importance"]],
            -item["confirmation_count"],
            int(item["id"][2:]),
        ),
    )
    result: dict[str, Any] = {
        "ok": True,
        "command": "query",
        "initialized": True,
        "revision": state["state_revision"],
        "items": [],
        "omitted": len(candidates),
    }
    for item in candidates:
        compact = {
            "id": item["id"],
            "kind": item["kind"],
            "scope": item["scope"],
            "assertion": item["assertion"],
        }
        result["items"].append(compact)
        result["omitted"] = len(candidates) - len(result["items"])
        payload = json.dumps(result, ensure_ascii=False, sort_keys=True) + "\n"
        if len(payload.encode("utf-8")) > QUERY_MAX_BYTES:
            result["items"].pop()
            result["omitted"] += 1
            break
    require(len((json.dumps(result, ensure_ascii=False, sort_keys=True) + "\n").encode("utf-8")) <= QUERY_MAX_BYTES, "query result exceeds its fixed byte budget")
    return result


def command_check(workspace: Path) -> dict[str, Any]:
    path = state_path(workspace)
    require(path.exists(), "author memory is not initialized")
    state = validate_state(read_json(path))
    views = render_views(state)
    root = memory_root(workspace)
    for name, expected in views.items():
        view_path = root / name
        require(view_path.exists(), f"missing derived view: {view_path}")
        require(view_path.read_text(encoding="utf-8") == expected, f"derived view is stale or edited: {view_path}")
    counts = {status: sum(1 for item in state["items"].values() if item["status"] == status) for status in STATUSES}
    return {"ok": True, "command": "check", "revision": state["state_revision"], "counts": counts}


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description=__doc__)
    subparsers = parser.add_subparsers(dest="command", required=True)
    for command in ("init", "check"):
        child = subparsers.add_parser(command)
        child.add_argument("--workspace", required=True, type=Path)
    commit = subparsers.add_parser("commit")
    commit.add_argument("--workspace", required=True, type=Path)
    commit.add_argument("--input", required=True, type=Path)
    record = subparsers.add_parser("record")
    record.add_argument("--workspace", required=True, type=Path)
    record.add_argument("--input", required=True, type=Path)
    query = subparsers.add_parser("query")
    query.add_argument("--workspace", required=True, type=Path)
    query.add_argument("--kind", action="append", choices=KINDS)
    query.add_argument("--book")
    query.add_argument("--genre")
    query.add_argument("--workflow")
    return parser


def main() -> int:
    args = build_parser().parse_args()
    try:
        if args.command == "init":
            result = command_init(args.workspace)
        elif args.command == "commit":
            result = command_commit(args.workspace, args.input)
        elif args.command == "record":
            result = command_record(args.workspace, args.input)
        elif args.command == "query":
            result = command_query(args.workspace, args.kind, args.book, args.genre, args.workflow)
        else:
            result = command_check(args.workspace)
        emit(result)
        return 0
    except AuthorMemoryError as exc:
        emit({"ok": False, "error": str(exc)}, error=True)
        return 2


if __name__ == "__main__":
    raise SystemExit(main())
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 先否定后肯定, 实战漏网句式)
  - 工整并列 (至于X不X,怎么X / 同动词「不V A,不V B」,含台词,advisory)
  - 反序对比 (是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 / trailer-summary). 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 / stock-reaction-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 / formulaic-parallelism,是提示,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;

// 套式反应细节:不是禁写身体,而是提示成片出现的“部位 + 轻微动作/状态”、
// “胸口像被撞了一下”、喉结/眼圈/声音放轻等通用情绪尾巴,以及“平静语气 +
// 像在念/宣判”模板。此类句子词面变化大,不能逐词 blocking;按章聚集到 4 处才
// advisory,要求逐处做删除测试。正常受伤、打斗、生理反应若承担物理后果可保留。
const STOCK_REACTION_PATTERNS = [
  /(?:指尖|手指|指节|手背|掌心|拳头|袖口|衣角|裙角|下唇|嘴唇|唇角|嘴角|眉头|眼底|眸光|目光|视线|肩膀|呼吸)[^。!?!?\n]{0,16}(?:轻轻|微微|缓缓|悄然|不自觉|无意识|下意识|攥紧|握紧|收紧|绞紧|泛白|发白|叩|敲|摩挲|抿紧|抿成|移开|垂下|躲开|一颤|颤了?一下|停了?一下|顿了?一下)/g,
  /(?:语气|声音)[^。!?!?\n]{0,12}(?:平静|冷静|平淡|冷淡|淡漠|平直)[^。!?!?\n]{0,12}(?:像|仿佛|如同|好像)[^。!?!?\n]{0,16}(?:念|读|报|说|陈述|宣判|背诵)/g,
  /(?:胸口|心口)[^。!?!?\n]{0,16}(?:像|仿佛|如同|好像)[^。!?!?\n]{0,16}(?:撞|锤|压|攥|堵)[^。!?!?\n]{0,8}(?:一下|一记|一拳)?/g,
  /(?:声音|嗓音|语气)[^。!?!?\n]{0,12}(?:放轻|压低|发紧|发颤|很轻|轻了些)/g,
  /(?:喉结|喉头|喉咙)[^。!?!?\n]{0,10}(?:滚|动|紧|堵|发涩|发干)/g,
  /(?:眼眶|眼圈|鼻子)[^。!?!?\n]{0,8}(?:发红|红了|发热|发酸|一酸)/g,
  /(?:抿了?下唇|抿了?抿唇|抿了?下嘴|抿着笑)/g,
];
const STOCK_REACTION_MIN_HITS = 4;
// 校准(真人语料,<br> 已还原为换行):qimao 长篇 5584 章 + heiyan 短篇整篇 3983 篇。
// 长篇章尺度(中位约 2100 字)per-kilo 1.0→1.5 误报 0.43%→0.39%,几乎不动;
// 短篇整篇 8000-20000 字下 MIN_HITS 形同虚设、只剩密度门,1.0 时误报 5.57%,
// 1.5 降到 1.46%。故取 1.5,把两个总体拉到同一量级(四份副本共用一组阈值)。
const STOCK_REACTION_PER_KILO = 1.5;

// 监控摄像头式动作清单:同一段连续堆叠通用动作动词(伸手/拿起/取过/挑开/放下/转身等),
// 且用逗号/顿号串联成步骤表时,读感像无视角温度的监控记录。只做 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 共用一份来源。
// 引号片段一律不跨行(字符类里排掉 \n):正文漏一个收引号很常见(多段台词只在末段收尾、
// 全半角引号混用都会漏),若允许跨行配对,一个未闭合的开引号会把后面成百上千字全算成
// 「引号内」,让 quotedRanges 的消费方(not-is 跨行扫描)把整段叙述静默豁免掉。
const QUOTE_PAIRS = [['「', '」'], ['『', '』'], ['【', '】'], ['“', '”'], ['‘', '’'], ['"', '"'], ["'", "'"]];
const QUOTE_SOURCES = QUOTE_PAIRS.map(([open, close]) => `${escapeRegExp(open)}[^${escapeRegExpCharClass(close)}\\n]*${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」段——真人叙述里「不哭不闹」类太常见,收进来误报换不来收益。
// 光杆「没」还得挡两类非否定用法,否则正常叙述会被判成排比:
//   1) 黏着语素(沉没/淹没/埋没/出没/隐没…)——前字排除,「船沉没在雾里,没人回头,…只有…」不算;
//   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,
];
const CROSS_NEGATION_START = /^不是[^。!?!?\n]{1,24}[。!?!?]?$/;
const CROSS_NEGATION_MIDDLE = /^(?:也|还)不是[^。!?!?\n]{1,24}[。!?!?]?$/;
const CROSS_NEGATION_END = /^只是[^。!?!?\n]{1,32}[。!?!?]?$/;

// 两类常见但不能直接判错的工整框架,只做 advisory。与 blocking 规则不同,这里故意扫描
// 台词:自然点单「不放辣,不放葱」靠对象最短长度排除;更长的同动词清单交语义审查判断功能。
const DECISION_FRAME_PATTERN = /至于([\u3400-\u9fff]{1,3})不\1[,,]\s*怎么\1/g;
const REPEATED_NEGATIVE_VERB_PATTERN = /不([\u3400-\u9fff]{1,2})([\u3400-\u9fff]{2,8})[,,]\s*不\1([\u3400-\u9fff]{2,8})/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;

// 章尾状态总结体:把细纲「结尾设定/收束状态」原样写成总结句收章(「这一夜注定无人入眠」
// 「这一切都结束了」「新的人生才刚刚开始」「命运的齿轮」)。与 trailer-ending 共用文末窗口,
// 区别是它盖章过去、trailer-ending 预告将来;收的都是 banned-words 已按名禁掉的形态。
// 不收「(这|那)一刻…终于明白」:真人语料里那是正常的认知节拍,短篇第一人称审判句还是卖点
// (short-craft「审判金句 / 心死余韵」),密度型由 advisory 的 abstract-summary-tic 兜。
// 各分支都要求落在句末断言位,否则会吃进条件从句(等这一切结束了,我们就…)、动补
// (这一切都说明得非常清楚)、成语跨匹配(这一刻…命中注定)、系表(这一战的结果是注定的)、
// 及物用法(就这样…才结束了这个话题)、场内报幕(就这样…宣布…圆满落幕)和否定认知
// (他不知道这一切意味着什么)——最后一类靠 (?!什么) 排掉间接疑问,那是盖章的反面。
// 校准(文末 600 字窗口,命中逐条人工复核):qimao 章中段 20000 章命中 1 处(0.005%)、
// heiyan 整篇 3999 篇命中 22 处(0.550%,全部是上列禁用形态);同批既有 trailer-ending
// 分别命中 1.345% / 6.602%——本规则误报面显著小于已上线的同窗口规则。短篇整篇即收口,
// 基线天然高于长篇章中段,故两个总体分别报数。
const TRAILER_SUMMARY_PATTERN = /这一(?:夜|天|刻|战|年|局|役)[,,]?[^。!?!?,,\n]{0,6}(?<!命中)(?<!是)注定[^。!?!?\n]{0,8}[。!]|就这样[,,][^。!?!?,,\n]{0,8}(?:一切|全部)[^。!?!?,,\n]{0,4}(?:结束了|落幕|收场)[。!]|这一切[,,]?[^。!?!?,,\n]{0,6}(?:都)?(?:说明|意味着|结束了)(?!的)(?:(?!什么)[^。!?!?\n]){0,6}[。!]|(?:新的篇章|新的旅程|崭新的篇章|新的人生)[^。!?!?\n]{0,6}(?:开始|拉开|展开)|命运[^。!?!?\n]{0,6}齿轮/g;

// 引号强调滥用(实战漏网 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(...findFormulaicParallelism(proseLines));
  findings.push(...findReverseNotIs(proseLines));
  findings.push(...findTrailerEnding(proseLines));
  findings.push(...findQuoteEmphasisTic(proseLines));
  findings.push(...findPeriodStutter(proseLines));
  findings.push(...findMicroActionTic(proseLines));
  findings.push(...findStockReactionTic(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;
}

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

  for (const { text, lineNo } of proseLines) {
    const trimmed = text.trim();
    if (!trimmed || isDivider(trimmed) || isStructural(trimmed)) continue;
    for (const [pattern, message] of [
      [DECISION_FRAME_PATTERN, '「至于X不X,怎么X」把同一决定拆成工整栏目;若只是复述细纲,压成角色当下的一次判断或直接动作。'],
      [REPEATED_NEGATIVE_VERB_PATTERN, '同动词「不V A,不V B」容易写成否定清单;含台词也要按语境复核,保留真正有功能的一项即可。'],
    ]) {
      pattern.lastIndex = 0;
      let match;
      while ((match = pattern.exec(text)) !== null) {
        findings.push({
          line: lineNo,
          column: match.index + 1,
          type: 'formulaic-parallelism',
          severity: 'advisory',
          message,
          excerpt: compact(match[0]),
        });
      }
    }
  }

  // 跨段「不是A / 也不是B / 只是C」既可能是细纲复述,也可能是正常的
  // 辩解、悬念排除或情绪递进。纯句法无法稳定区分,因此只给 advisory,交给语义复核。
  const window = [];
  for (const { text, lineNo } of proseLines) {
    const trimmed = text.trim();
    if (!trimmed) continue;
    if (isDivider(trimmed) || isStructural(trimmed)) {
      window.length = 0;
      continue;
    }
    if (window.length && lineNo - window[window.length - 1].lineNo > 2) window.length = 0;
    window.push({ text: maskQuoted(trimmed), original: trimmed, lineNo });
    if (window.length > 3) window.shift();
    if (window.length !== 3) continue;
    if (!CROSS_NEGATION_START.test(window[0].text)
      || !CROSS_NEGATION_MIDDLE.test(window[1].text)
      || !CROSS_NEGATION_END.test(window[2].text)) continue;
    findings.push({
      line: window[0].lineNo,
      column: 1,
      type: 'formulaic-parallelism',
      severity: 'advisory',
      message: '跨段「不是… / 也不是… / 只是…」可能是工整否定铺排,也可能承担辩解或悬念排除;通读语境,只在重复细纲或拖慢画面时改写。',
      excerpt: compact(window.map((entry) => entry.original).join(' / ')),
    });
  }

  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)),
      });
    }
    TRAILER_SUMMARY_PATTERN.lastIndex = 0;
    let summaryMatch;
    while ((summaryMatch = TRAILER_SUMMARY_PATTERN.exec(masked)) !== null) {
      findings.push({
        line: lineNo,
        column: summaryMatch.index + 1,
        type: 'trailer-summary',
        severity: 'blocking',
        message: '章尾状态总结体:「这一夜注定…/这一切都结束了/新的人生才刚刚开始/命运的齿轮」是把细纲的收束状态原样写成了总结句;收束状态是规划口径,正文落到最后一个具体动作、画面或台词上,别替读者盖章。',
        excerpt: compact(text.slice(summaryMatch.index, summaryMatch.index + summaryMatch[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 findStockReactionTic(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 STOCK_REACTION_PATTERNS) {
      pattern.lastIndex = 0;
      let match;
      while ((match = 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);
      }
    }
  }

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

  return [{
    line: firstLine,
    column: 1,
    type: 'stock-reaction-tic',
    severity: 'advisory',
    message: `套式反应细节:指尖/指节/喉结/眼圈/声音放轻等通用反应或“平静得像在念”式语气比喻 ${hits} 处(${perKilo.toFixed(1)}/千字);逐处做删除测试,只标注情绪、不改变选择、关系、物件或动作结果的删掉,不要换部位或同义动作。`,
    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 会移位,不适合定位)。
// 占位字符用「?」而不是「。」:占位既要截断各规则的 [^。!?!?…] 否定类(?与句号在每条
// 规则的否定类里等效),又不能落在任何规则的接受位。句号占位会替 trailer-summary 的句末
// [。!] 伪造出终止符,让「这一战注定是「血屠」的开端,…」这类引号里放代号/绰号的叙述行
// 被误报,且报出的『这一战注定是。』在原文里 grep 不到。占位长度不变,故偏移与摘录窗口不漂移。
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,我会保护你」是合法对话)。
  // 型号后缀(AI语言模型/AI助手/人工智能语言模型/AI模型/AI大模型)必须可选吃掉:否则前视断言紧跟
  // 在「AI」后面看到的是「语」/「助」/「模」,最典型的退化开场整类漏检(与写后网 story_hook_core.js
  // SOFT_PATTERNS / story_codex_hook.py _NET_SOFT_PATTERNS 同语义)。
  { 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 { lines, endings } = splitLinesKeepingEndings(input);

  const findings = [];
  const outputLines = [];
  let fence = null;
  let inFrontMatter = hasYamlFrontMatter(lines);
  let quoteOpen = false;
  let commentOpen = false;
  let commentStart = null;
  const commentCloseAhead = new Array(lines.length + 1).fill(false);
  for (let index = lines.length - 1; index >= 0; index -= 1) {
    commentCloseAhead[index] = lines[index].includes('-->') || commentCloseAhead[index + 1];
  }

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

    // 未闭合的 `<!--` 不能把余下整篇伪装成注释。确认 EOF 前已无 `-->` 时,
    // 在起始位置具名报错,并从当前行恢复正文扫描;起始符所在行仍原样保护。
    if (commentOpen && !commentCloseAhead[index]) {
      findings.push({
        line: commentStart?.line || lineNo,
        column: commentStart?.column || 1,
        type: 'html-comment-unclosed',
        message: 'HTML 注释未闭合;后续内容仍按正文检查。',
      });
      commentOpen = false;
      commentStart = null;
    }

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

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

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

    // 跨行 HTML 注释里的 `---` 是注释内容,不是正文分隔线。
    if (trimmed === '---' && !commentOpen) {
      findings.push({
        line: lineNo,
        column: line.indexOf('-') + 1,
        type: 'markdown-divider',
        message: '正文中不要使用 markdown 分隔线;建议移除该行。',
      });
      continue;
    }

    const commentOpenBefore = commentOpen;
    const punctuationResult = normalizePausePunctuation(line, lineNo, commentOpen);
    findings.push(...punctuationResult.findings);
    line = punctuationResult.line;
    commentOpen = punctuationResult.commentOpen;
    if (!commentOpenBefore && commentOpen) {
      commentStart = { line: lineNo, column: Math.max(1, line.lastIndexOf('<!--') + 1) };
    } else if (!commentOpen) {
      commentStart = null;
    }

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

    outputLines.push(line + ending);
  }

  if (commentOpen) {
    findings.push({
      line: commentStart?.line || lines.length,
      column: commentStart?.column || 1,
      type: 'html-comment-unclosed',
      message: 'HTML 注释未闭合;后续内容仍按正文检查。',
    });
  }

  return {
    output: outputLines.join(''),
    findings,
  };
}

// 逐行记住原始行尾。整篇按「文件里出现过 \r\n」统一行尾会让一个孤立 CRLF 把全文
// 行尾都翻成 CRLF——那是一次没人要求的全文件 diff,而 --check 对行尾一个 finding
// 都不报,只改标点的这一步不该动它。
function splitLinesKeepingEndings(input) {
  const lines = [];
  const endings = [];
  let cursor = 0;

  while (cursor < input.length) {
    const newlineIndex = input.indexOf('\n', cursor);
    if (newlineIndex === -1) {
      lines.push(input.slice(cursor));
      endings.push('');
      break;
    }
    const crlf = newlineIndex > cursor && input[newlineIndex - 1] === '\r';
    lines.push(input.slice(cursor, crlf ? newlineIndex - 1 : newlineIndex));
    endings.push(crlf ? '\r\n' : '\n');
    cursor = newlineIndex + 1;
  }

  return { lines, endings };
}

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);
}

// 删空停顿符会把两侧的半角点/连字符粘成新的 `...`/`--`(`他.……..说` → `他...说`),
// 一遍归一化留不干净,再跑一遍还会改已定稿的正文;所以反复归一化到不动点。
// 每遍至少把一个 `…/./—/-` 换成非停顿字符,字符数严格递减,必然收敛。
// findings 只留第一遍:同一处不重复计数,column 也仍然是原行的偏移。
function normalizePausePunctuation(line, lineNo, commentOpen) {
  let current = line;
  let findings = null;
  let commentOpenAfter = commentOpen;

  for (;;) {
    const comments = htmlCommentSpans(current, commentOpen);
    commentOpenAfter = comments.open;
    const pass = normalizePausePunctuationPass(current, lineNo, comments.spans);
    if (findings === null) findings = pass.findings;
    if (pass.line === current) break;
    current = pass.line;
  }

  return { line: current, findings, commentOpen: commentOpenAfter };
}

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

  while ((match = pattern.exec(original)) !== null) {
    const token = match[0];
    // HTML 注释是正文里的元信息(如 `<!-- 去味:跳过 -->` 豁免标记):`<!--`/`-->` 里的
    // `--` 不是停顿标点,改掉它注释就散了,标记会变成读者看得见的正文。
    if (insideSpans(match.index, match.index + token.length, commentSpans)) continue;
    output += original.slice(lastIndex, match.index);
    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 };
}

// 行内 HTML 注释区间(含 `<!--`、`-->` 本身);注释可跨行,未闭合时把状态交给下一行。
function htmlCommentSpans(line, openBefore) {
  const spans = [];
  let open = openBefore;
  let cursor = 0;

  while (cursor < line.length) {
    if (open) {
      const close = line.indexOf('-->', cursor);
      if (close === -1) {
        spans.push([cursor, line.length]);
        return { spans, open: true };
      }
      spans.push([cursor, close + 3]);
      cursor = close + 3;
      open = false;
      continue;
    }

    const start = line.indexOf('<!--', cursor);
    if (start === -1) break;
    cursor = start;
    open = true;
  }

  return { spans, open };
}

function insideSpans(start, end, spans) {
  return spans.some(([spanStart, spanEnd]) => start < spanEnd && end > spanStart);
}

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

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

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

---

> Agent 兼容性:只检查当前运行时的 canonical 目录:Claude `.claude/agents/{agent}.md`、OpenCode `.opencode/agents/{agent}.md`、Codex `.codex/agents/{agent}.toml`、Antigravity `.agents/agents/agent-name/agent.md`(`agent-name` 为目标 agent 名),不得因其他端文件存在而误判。Codex 使用同名 `agent_type`;Antigravity 使用 `invoke_subagent` + `TypeName`。对应运行时未暴露 custom-agent registry / `invoke_subagent` 或返回未知 agent 时,必须降级 solo/direct。检测到 `.zcode/` 时同样直接 solo/direct,因为 ZCode 3.3.4 不执行项目 custom agents;报告 `Fallback: project custom agents unavailable -> solo`。Claude/OpenCode 兼容面保留 `subagent_type`。
>
> Spawn 版本提示(不阻断 spawn):先读取项目根 `.story-deployed` 的 `agents_version`。与本版 `agents_version: 28` 不一致时(标记缺失、字段缺失/非整数、小于或大于 28)**照常按文件存在性检查并 spawn**,同时报告 `Notice: agents bundle 版本不匹配(项目 {N},本版 28)` 并提示重新运行 `/story-setup` 后新开会话;大于 28 时额外提示先更新 oh-story-claudecode,不要用本地旧版 setup 降级覆盖。只有 agent 文件缺失、或运行时不暴露 custom agent 时才降级 solo/direct,报告 `Fallback: ... -> solo`。

## 核心哲学

### 原则 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味仍以原文剧情边界为准,不把表达修复变成新增情节或新增事件链。

### 作者习惯

若作者记忆 state 已存在,改写前用 `scripts/author_memory_commit.py query --kind prose_style` 获取匹配的 active 文风条目(总输出 ≤2KB),并交给 inline/spawn 执行者作为自然倾向,不逐条展示或最大化命中,不牺牲连贯、节奏和字数;当前请求、原文剧情功能和本 skill 保护规则优先。用户明确声明长期文风习惯时,改写后按 [references/author-memory.md](references/author-memory.md) 用 `record` 写入并回传回执;重复修正/推断先待确认,一次性要求、检测器 findings 和助手自己的结果不记录。

---

## 自然文本基准

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

### 自然文本特征(与AI味对比)
| 维度 | 自然文本 | AI味文本 |
|------|----------|--------|
| 段落长度 | 随 beat 长短不一:爽点/转折压短,推理/氛围/情绪链放长 | 通篇同一长度,整齐均匀 |
| 句内节奏 | 叙述以逗号长句为主(逗号之间 8-12 字、整句 20-30 字,见 anti-ai-writing.md 规则 3) | 要么长句臃肿,要么通篇碎句像提纲 |
| 对话标签 | 标签低频且不公式化,多用动作/上下文引出;普通"说"可保留 | 几乎每句都有"说道/问道/笑道" |
| 情绪表达 | 动作展示("手在抖") | 直接告诉("很紧张") |
| 比喻 | 生活化("像哈士奇护食") | 文学化("如寒冰般") |
| 语气词 | "嘤""嘶""靠""行吧" | 几乎没有 |
| 省略 | 大量省略,读者自己脑补 | 面面俱到,生怕读者不懂 |
| 排比 | 偶尔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 自带脚本,只报告不修改:

```bash
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` / `trailer-summary`)并入 Gate B,属于写作/去 AI 味时优先处理的 blocking 类问题。
- 其他 findings(碎句号、长段落、微动作、套式反应细节、动作清单、抽象总结、套词、比喻密度、解释链、公文腔、过度精炼、低连接密度、引号强调滥用、`formulaic-parallelism` 工整并列)只作读感提示;完整类别和修法见 `references/anti-ai-writing.md`。其中工整并列会扫描台词,必须读语境判断,不能因为 hook 对台词低误报豁免就跳过。
- 处理方式:删掉否定铺垫,直接写后项;或改成角色动作、物件细节、身体反应来呈现。
- 若用户只要检测,保留报告不改文。若执行去 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味打分客观指标**:

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

> 备注:核心场景(开篇、高潮、收束)出现 1 次重复描写即按 ≥1 档加权(轻→中,中→重)。
>
> 以上阈值为参考值,需结合题材特点调整。例如古风题材的对话标签密度天然偏高,应适当放宽。
>
> **综合判定规则**:取六项指标中的最高档位。任一指标达重度即按重度处理;无重度时,中度指标 ≥3 项按中度处理,否则按轻度处理。

加载 [references/anti-ai-writing.md](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. **未在子代理内且按顶部顺序找到 `narrative-writer` agent**:按当前运行时调用;Antigravity 用 `invoke_subagent(TypeName: "narrative-writer")`,Claude/OpenCode/Codex 用各自字段。prompt 保持:`项目目录:{dir}\n任务描述:去AI味\n检查范围:{待处理的正文文件}\n作者偏好:{query 命中的 prose_style 项}\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](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 句式之一 | 直接写后项,或改成动作/细节呈现 |
| 跨段「不是A / 也不是B / 只是C」 | 可能是工整铺排,也可能是辩解、悬念排除或情绪递进 | `formulaic-parallelism` advisory;通读语境,仅在重复提纲或拖慢画面时压缩 |
| 「至于X不X,怎么X」/同动词「不V A,不V B」 | 工整决策栏、否定清单;正常台词也可能出现 | 结合语境复核;若只是复述前文或细纲,压成一次判断或只留一项 |
| "...,带着..." | 万能状语,AI最爱 | 用独立短句或动作描写 |
| "声音不大,却带着……" | AI 最爱声音描写 | 直接写声音特征或动作 |
| 陈词滥调/万能比喻 | 公式化比喻会显 AI 腔 | 优先直接白描;确需比喻时只留少数生活化、角色化比喻 |
| "他/她知道..." | 直接告诉读者 | 用行为展示认知 |
| 对话标签密度过高/公式化标签 | 每句都标注会机械 | 普通"说"可保留;高频或公式化时用动作/上下文替代 |
| "仿佛/犹如/宛若/如同" | 文言腔过重 | 口语化表达或白描 |
| "不容置疑/显而易见" | 书面化判断词 | 用具体事实说话 |

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

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

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

#### 门禁 C:心理描写外化

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

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

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

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

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

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

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

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

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

#### 门禁 D:节奏调整

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

处理方法:
- 打断连续排比句(保留1-2个,删掉其余)
- 只拆臃肿修饰、堆叠比喻、抽象总结的长句;改写后叙述仍以逗号长句为主(见 anti-ai-writing.md 规则 3),不要把正常的逗号长句拆成短句串
- 偶尔用不完整句(口语感)
- 段落长短交错(不要每段都3-5行)
- 不按硬指标排版:番茄高分样本不是 50-60 字一行,也不是逢句号必换行;按动作/信息变化自然断段,读起来不卡即可
- 标点节奏跟语气走:避免通篇句号化;保留有功能的 `?` / 少量 `!`,把 `……` / `——` 改成动作、短句、换行、逗号或句号,删除随机堆砌或刷屏符号

#### 门禁 E:对话去腔调

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

处理方法:
- 加入口语化表达("嗯""哦""行吧")
- 适当打断对话(角色可以答非所问);对话被打断或拖长时用动作、换行或短句处理,不用 `——`
- 用动作穿插对话("她喝了口水。'然后呢?'")
- 删掉解释性对话(角色不会把自己的动机说清楚)
- Gate B 同样检查台词:连续工整否定、`至于X不X,怎么X`、同动词 `不V A,不V B` 不能因脚本的台词豁免而漏审;有明确人物/任务功能才保留
- 不为凑比例硬扩台词;番茄对话占比随题材波动,台词只在角色此刻真会说、必须说时增加
- 口误、停顿、粗话和重复要服务人物身份与情绪,不作为“真人感”装饰批量添加
- 不把所有对话末尾改成句号:质问保留问号,爆发峰值保留少量感叹;吞回去/没说完用动作停顿、短句或换行,不用 `……`

#### 门禁 F:结尾去升华

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

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

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

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

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

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

---

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

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

```bash
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 的类别优先改正文并复扫;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`。

---

### 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/banned-words.md) | 检测和替换禁用词时 |
| [references/anti-ai-writing.md](references/anti-ai-writing.md) | **去AI味完整指南**:预防+三遍法+范例 |
| [scripts/normalize-punctuation.js](scripts/normalize-punctuation.js) | 文件模式落盘后做确定性标点收尾;默认保留引号风格 |
| [scripts/check-ai-patterns.js](scripts/check-ai-patterns.js) | 文件模式「AI味扫描」预检与「确定性收尾」复扫(只看引号外叙述),只报告不改写 |
| [scripts/check-degeneration.js](scripts/check-degeneration.js) | 文件模式「确定性收尾」复扫,只报告不改写 |
| [references/author-memory.md](references/author-memory.md) + [scripts/author_memory_commit.py](scripts/author_memory_commit.py) | 读取或更新跨会话作者文风习惯时 |

---

## 流程衔接

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

| 时机 | 跳转到 | 命令 |
|---|---|---|
| 继续写作 | 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` |

---

## 语言

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