返回 Skills 目錄
zenstory-ai/oh-story-claudecode已通過檢查

SKILL DETAIL

story-review

zenstory-ai/oh-story-claudecode/story-review

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

安裝量 · 89查看來源

Installation

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

技能檔案

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` 报警时,不代表禁止身体描写。逐处问:删掉后信息、选择、关系、物件或动作结果是否受损?无损就删,不把“指尖轻叩”换成“目光微沉”。伤势、动作失败、人物习惯或情节后果明确时可以保留。
references/character-relations.md
# 角色关系与感情线操作手册

## 决策路由

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

---

## 人物关系类型

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

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

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

---

## 感情流人设核心法

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

### 构建步骤

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

### 执行规则

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

---

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

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

### 男频爱情线逻辑

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

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

### 女频爱情线逻辑

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

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

---

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

### 必须满足的条件

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

### 信息差设计

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

---

## 修罗场收场策略

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

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

---

## 傻白甜角色塑造避坑

### 六个必避之坑

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

### 反向用法

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

---

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

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

### 核心思路

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

### 执行规则

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

---

## 角色行为自洽检查

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

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

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

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

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

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

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

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

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

---

## 配角攻略缓冲区

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

### 缓冲区类型

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

### 操作步骤

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

### 执行规则

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

---

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

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

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

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

---

## 亦敌亦友关系

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

### 执行规则

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

---

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

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

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

### 亲情线的四个方向

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

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

---

## 男频恋爱文写法攻略

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

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

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

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

### 好感度升级路径

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

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

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

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

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

### 写女主对主角好

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

### 善用对比

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

---

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

### 四阶段

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

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

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

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

### CP行为三类门槛

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

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

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

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

---

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

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

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

### 感情线的层次设计

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

### 配角的功能性定位

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

---

## 绿茶/负面角色

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

---

## 质量检查清单

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

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

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

---

## 决策路由

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

### 对话模式选择补充

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

---

## 对话核心规则

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

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

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

### 绝对禁止

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

---

## 权力博弈对话

### 规则

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

### 压制模式

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

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

### 反转模式

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

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

### 心死模式

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

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

### 操作指令

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

---

## 潜台词与议程

### 潜台词规则

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

### 对话议程

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

### 语气由三要素决定

关系 × 场合 × 目的 = 语气

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

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

---

## 情绪推动对话

### 强情绪对话

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

### 情绪连续性

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

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

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

### 对话不平淡三思路

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

---

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

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

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

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

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

---

## 人物语言差异化

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

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

---

## 弹幕/群众对话

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

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

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

### 操作要点

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

---

## 节奏控制

### 大量对话保持节奏

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

### 动作和表情处理

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

### 对话的呼吸感

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

---

## 篇幅控制

### 对话过多时

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

### 对话过少时

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

---

## 以梗填充对话

### 梗式 vs 普通

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

### 操作

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

---

## 质量检查

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

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

### 核心指令检查

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

### 检验对话质量

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

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

---

## 决策路由表

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

---

## 小纲四步法

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

按以下四步操作:

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

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

---

## 高潮逆推法与AB粗纲

### 核心思路

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

### AB粗纲法

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

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

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

---

## 高潮构建公式

### 五步公式

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

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

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

---

## 噱头分类与开篇流程

### 三种噱头类型

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

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

### 两种标准开篇流程

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

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

### 噱头吸量策略

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

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

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

---

## 主线的正确定义

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

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

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

---

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

### 核心公式

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

### 6种经典循环模式

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

### 地图四势力框架

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

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

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

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

### 换地图三策略

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

### 特殊类型处理

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

---

## 日常文大纲框架法

### 创作路径

按以下步骤操作:

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

### 大纲推演法

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

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

### 大纲节点格式

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

### 关键原则

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

---

## 大剧情拉期待法

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

按顺序执行:

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

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

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

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

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

---

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

### 热度状态

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

### 有效触碰判定

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

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

### 回顾阈值

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

### 每章必做自检

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

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

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

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

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

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

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

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

### 过渡章节管理

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

### 换地图期待感延续

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

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

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

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

### 矛盾网设计

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

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

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

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

### 持续拉期待的方法

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

### 升级差异化管理

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

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

### 单元故事嵌套

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

### 长线节奏设计

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

### 期待感管理

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

---

## 剧情过渡与衔接

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

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

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

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

### 场景过渡

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

### 情绪衔接

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

### 信息差衔接

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

---

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

### 门槛的本质

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

### 设门槛的具体方法

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

### 规则

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

### 循环从设定中诞生

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

---

## 场景转换技巧

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

### 换地图的深层设计

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

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

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

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

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

---

## 悬念与伏笔技巧

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

---

## 信息团概念

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

---

## 自嗨判定法

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

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

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

### 纠正方法

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

---

## 质量检查清单

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

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

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

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

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

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

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

## 评分方法

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

## 核心维度

| 维度 | PASS | WARN | FAIL |
|---|---|---|---|
| 核心卖点 | 章节围绕明确卖点推进,读者知道为什么继续看 | 卖点存在但表达弱或被支线稀释 | 看不出本章服务什么卖点或主线 |
| 冲突推进 | 本章有明确冲突、阻碍、选择或代价 | 冲突较轻,推进感不足 | 主要是解释/闲聊/总结,无实际推进 |
| 任务卡点 | 角色办事被卡住,并卡出信息、关系、代价、选择或伏笔变化 | 有卡点但变化偏弱,可压缩 | 卡点只是流程细节,删掉不影响故事 |
| 情绪曲线 | 有铺垫、升温、释放或反转 | 情绪有变化但节点不清 | 情绪平直或突兀转向 |
| 钩子与期待 | 开头/结尾至少一处建立期待 | 钩子弱但仍有后续问题 | 没有悬念、目标或未完成期待 |
| 开头新鲜度(仅开篇/前 3 章) | 开局有具体人物/处境切口,不是同题材默认套路 | 有钩子但开局形状与同题材撞车 | 开局是题材默认模板,可整体换到任意同类书(同质化) |
| 角色动机 | 行为符合目标、性格、处境和关系压力 | 局部行为缺少铺垫 | 角色为推动剧情而失真 |
| 对话质量 | 对话有潜台词、信息控制和角色差异 | 有信息堆叠或声音略同质 | 对话像说明书,人人同腔 |
| 设定一致性 | 不违背既有规则、时间线和角色属性 | 有轻微模糊点需补证据 | 与已写设定或前文事实冲突 |
| 文字自然度 | 具体、可感、动作承载信息 | 偶有套话或抽象总结 | AI 腔、陈词滥调、总结体明显 |
| 句长节奏 | 叙述默认是逗号长句,短句只作偶尔的孤立重拍、用完回到逗号长句 | 局部碎句或电报体,或偶发机械长短交替 | 逗号之间连着都是 ≤5 字、通篇超短句像提纲,或审改把逗号长句拆碎 |
| 标点节奏 | 标点跟语气、人物声线和情绪功能匹配 | 局部标点单调或略有堆砌 | 通篇句号化、随机堆砌问号/感叹号,或用 `……`/`——` 硬造停顿 |
| 具体字数表达校验 | 「这五个字」「短短四字」类表达的字数经核对属实,且有叙事必要 | 字数属实但换成「那几个字」更自然 | 字数与实际不符,或无法确认统计口径 |
| 格式可读性 | 段落按戏剧单元/镜头自然断开,对话独立,无多余空行,主语节奏自然 | 局部段落偏长/偏碎或主语重复略多 | 大段堆叠、机械切段、对话/叙述难读、主语过密 |
| 剧情循环 | 目标→阻碍→行动→代价/反馈→新期待闭环清晰 | 有循环但缺一环或反馈偏弱 | 无目标、无阻碍或无反馈,读者不知道局势如何变化 |
| 高潮构建 | 蓄能→假胜→崩解→反转/兑现有层次 | 有爆点但蓄能或兑现不足 | 平铺直给、无代价、无兑现或情绪落空 |
| 关系进展 | 互动尺度匹配关系阶段,越界有铺垫 | 局部推进略快但可补证据 | 突然亲密/信任/敌对,角色关系跳变 |
| 伏笔状态 | 伏笔埋设、状态和回收路径可追踪 | 伏笔偏密/偏疏但不影响理解 | 伏笔与设定冲突、断线或造成主线理解混乱 |

## 黄金三问

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

## 发布建议门槛

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

## 输出要求

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

# 审稿质量检查清单

## 目录

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

## 一、通用检查

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

### 开篇检查(前 300-500 字)
- [ ] 有钩子,能抓住注意力
- [ ] 不从天气/风景/日常开始
- [ ] 主角快速出场
- [ ] 卖点或危机可见
- [ ] 开局不与同题材默认套路撞车(有钩子≠不同质;能整体换到任意同类书就是同质化)

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

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

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

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

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

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

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

---

## 二、长篇专项

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

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

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

### 五维评分标准

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

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

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

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

| 问题 | 严重度 | 修复 |
|------|--------|------|
| 照搬原句导致语气不自然 | medium | 只在确有 AI 腔时改写,正常原句可保留 |
| 大量使用 AI 标志词 | high | 替换为具体描述 |
| 同一句式重复出现 | medium | 变换表达方式 |
| 描写过于文学化(辞藻堆砌、书面腔、比喻成串) | medium | 改为口语化/动作化;动作化不是切成三五字短句串,改写后叙述仍以逗号长句为主 |

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

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

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

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

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

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

### 精修策略

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

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

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

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

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

---

## 三、短篇专项

### 虐爽节奏检查

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

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

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

### 对话密度检查

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

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

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

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

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

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

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

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

## 评分标准

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

## 使用方式

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

## 评分标准

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

## 使用方式

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

## 评分标准

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

## 使用方式

- story-review 的 story-architect 和 character-designer 视角引用此标准
- advisory only,不 blocking
- 盐言故事注重第一人称代入感和情绪拉扯
references/tracking-transaction.md
# 追踪状态协议

`追踪/` 使用“一个结构化权威状态 + 多个确定性派生视图”。模型只提交一份语义 JSON,不分别 `Write/Edit/echo >>` 多个追踪文件。

## 权威层与派生层

| 层级 | 文件 | 语义 |
|---|---|---|
| 唯一权威 | `_tracking-state.json` | schema、最后提交章、导入截止章、状态修订号、上下文结构、角色/伏笔/时间线,以及已提交章节的简短字数记录 |
| 章节记录 | `逐章记录/第NNN章.md` | 本章对未来连续性有用的紧凑变化;目标 ≤1536 字节,硬上限 3072 字节;导入范围内修订写成覆盖记录 |
| 派生视图 | `上下文.md`、`角色状态/{角色名}.md`、`伏笔.md`、`时间线/作者真相.md`、`时间线/读者已知.md` | 完全从 `_tracking-state.json` 生成;禁止手改,不作为程序输入 |

Markdown 只负责给作者和 Agent 阅读,工具不再反向解析 Markdown。`check` 直接从 `_tracking-state.json` 重渲染并逐文件比较。未来“第几章揭示”的计划写在卷纲/细纲,不写成时间线既成事实。
逐章记录只是便于人阅读的紧凑变化记录,不承诺单独无损重建全部当前状态;完整当前语义以 `_tracking-state.json` 为准。

## 运行工具

先按运行环境探测 Python 3 解释器(依次尝试 `python3`、`python`、`py -3`)。追踪事务脚本使用当前 skill 根目录;字数与章节闭环统一使用 `story-long-write` skill 根目录:

```text
{PYTHON} {当前 skill 根}/scripts/tracking_commit.py init   --project {书项目根} --input {初始化事务.json}
{PYTHON} {当前 skill 根}/scripts/tracking_commit.py check  --project {书项目根}
{PYTHON} {story-long-write skill 根}/scripts/storyctl.py wordcount checkpoint --file {前半段临时文件} --target {目标} --chapter {N}
{PYTHON} {story-long-write skill 根}/scripts/storyctl.py chapter check   --project {书项目根} --chapter {N}
{PYTHON} {story-long-write skill 根}/scripts/storyctl.py chapter commit  --project {书项目根} --chapter {N} --input {逐章事务.json}
{PYTHON} {story-long-write skill 根}/scripts/storyctl.py chapter accept-current-length --project {书项目根} --chapter {N} --input {逐章事务.json}
```

- `init`:只在 `_tracking-state.json` 不存在时执行,绝不覆盖已初始化项目。
- `wordcount checkpoint`:纯测量;返回当前实际字数、用户带与剩余用户区间,不写正文、不写 tracking、不做语义判断。每章最多调用一次。
- `chapter check`:重新读取当前正文与细纲目标,返回确定性长度状态、现有 blocking quality、`state_revision` 和当前可执行动作,不保存 approval。`under` 不提供自动补写;`over` 额外返回一次净删型 `compress-once` 及进入内带/用户带所需的机器删除区间。
- `chapter commit`:再次读取当前文件、重新计数并重跑 blocking quality;只接受用户带内章节,把简短字数记录与逐章事务一起原子提交。
- `chapter accept-current-length`:只接受带外但 quality pass 的章节;接受动作发生时重新读取、重新计数并立即原子提交,不保存可陈旧的历史决议。
- `check`:严格验证 state schema、逐章记录连续性/规范名/体积、固定 7 栏、角色快照硬上限、派生文件集合,以及所有派生视图与 state 的逐字一致性。

每本书由 `追踪/.tracking-commit.lock` 串行写事务,`expected_state_revision` 再拒绝基于旧状态构造的 stale transaction。两个不同事务并发时至多一个修订成功。字数记录也在锁内对当前正文和目标重新验证,正文或目标变化会让预先构造的记录直接失败。

事务 JSON 是临时输入,不是项目产物:成功前必须保留;提交成功且紧随其后的 `check` 通过后立即删除,不能把 `init_transaction.json`、`chapter_*_transaction.json` 等输入长期留在书项目根目录。若文件写入失败,`_tracking-state.json` 尚未推进;修正环境后直接重跑**同一份** `commit`。append 重跑只接受内容完全相同的既有逐章记录,不维护 `dirty/pending/repair` 状态机。

校验失败与写入失败处理方式不同:校验失败(字段非法、退役结构、容量超限)要按报错改事务本身,重跑同一份结果不变。派生视图被手改或外部改动导致 `check` 报 `derived view differs from _tracking-state.json` 时,重新提交**该章**的 `mode=revision` 事务让工具整份重建,`expected_state_revision` 取 `追踪/_tracking-state.json` 的 `state_revision` 字段——`check` 失败时只往 stderr 打 ERROR,不输出 JSON;不手改派生文件,也不删 `_tracking-state.json` 重来。手写出的逐章记录会让同章 `append` 永久报 `chapter delta N already exists with different content`——删掉那个手写文件后重跑原事务即可。

本工具不解析旧 `_tracking-meta.json`、`时间线/事件库.json` 或更早追踪结构,不提供语义兼容层。`init` 遇到这类旧文件时,先把它们按原样整体移入 `追踪/_旧追踪存档/`,再在原地建当前协议:旧内容留给作者查阅,不参与解析,当前状态完全以 init 输入为准。校验失败的 `init` 不移动任何文件。`commit` 与 `check` 仍直接拒绝旧结构——它们只在已建协议的项目上运行。

## 初始化事务

新书从第 0 章初始化。`story-import` 导入已有小说时把最后完整章写入 `last_chapter=N`;第 1..N 章不伪造日更记录,常规续写从 N+1 章开始。

```json
{
  "schema_version": 1,
  "book_title": "让你管账号,你高燃混剪炸全网",
  "last_chapter": 0,
  "context": {
    "position": {
      "volume": "第一卷·军宣整顿",
      "volume_start_chapter": 1,
      "story_time": "江晨到火箭军文工团报到前",
      "scene": "火箭军文工团"
    },
    "long_term_constraints": ["军宣爽点要用作品效果和围观反应链兑现,不能只靠系统播报"],
    "active_character_names": [],
    "continuity_risks": [],
    "recent_chapters": [],
    "next_chapter_commitments": ["让江晨报到,并落下五天百万粉的新手任务"]
  },
  "character_snapshots": {},
  "foreshadow": [],
  "timeline_events": []
}
```

导入初始化时直接传入当前核心角色快照、伏笔当前行、时间线事件和固定 7 栏状态输入。阶段/卷级回看按需查询正文,不作为每章强一致追踪产物。

调用方的逐章 JSON 不写 `wordcount`;正式入口 `chapter commit` 或 `chapter accept-current-length` 在提交当下生成并注入。最终 state 只为已提交章节保留 `metric / target / actual / status / resolution / body_sha256`,不保存 MEASURE/RESOLVE 事件、ID 链、policy fingerprint 或独立 chapter state。

## 逐章事务

```json
{
  "schema_version": 1,
  "mode": "append",
  "chapter": 10,
  "chapter_title": "专业团队拍得还不如他拍的好?",
  "expected_state_revision": 9,
  "delta": {
    "result": "专业团队重拍的高清版在高层看片会上被判定缺了灵魂,张耀祖拍板继续采用江晨的手机原版。",
    "character_changes": [
      {"name": "江晨", "change": "作品价值获军内高层确认,从爆款新人升为不可替代的军宣创作者"}
    ],
    "foreshadow_changes": [
      {
        "action": "upsert",
        "id": "F027",
        "summary": "专业团队仍拍不出江晨原版的灵魂,继续验证其创作能力不可复制",
        "planted_chapter": 10,
        "planned_resolution_chapter": null,
        "status": "已埋",
        "importance": "中"
      }
    ],
    "timeline_events": [
      {
        "action": "upsert",
        "id": "E010",
        "story_time": "实弹训练两天后",
        "objective_fact": "文工团高层否决专业重拍版,决定沿用江晨手机拍摄的原版视频",
        "reader_knowledge": "读者已看到周薄森指出专业版缺了灵魂,张耀祖当场拍板用回原版",
        "reveal_status": "已揭示",
        "reveal_chapter": 10,
        "characters": ["江晨", "周薄森", "张耀祖"]
      }
    ],
    "constraints": ["后续继续用作品落地效果和围观反应放大江晨的高光,不能只写系统奖励数字"],
    "next_chapter_commitments": ["结算五天百万粉任务,并承接老兵主题的新任务"]
  },
  "context": {
    "position": {
      "volume": "第一卷·军宣整顿",
      "volume_start_chapter": 1,
      "story_time": "实弹训练两天后",
      "scene": "火箭军文工团高层看片会"
    },
    "long_term_constraints": ["军宣爽点要用作品效果和围观反应链兑现,不能只靠系统播报"],
    "active_character_names": ["江晨"],
    "continuity_risks": ["钟嘉嘉说江晨只猜对一半,未公开的培养安排不能被当成读者已知事实"]
  },
  "character_snapshots": {
    "江晨": {
      "identity": "火箭军文工团宣传兵;军宣爆款创作者",
      "location": "火箭军文工团高层看片会",
      "goal": "完成五天百万粉任务,持续做出真正能打的军宣内容",
      "state": "专业团队反向验证原版价值,军内认可继续抬升",
      "abilities_resources": ["前世MCN爆款运营经验", "《中国军魂》伴奏", "大师级导演能力"],
      "relationships": ["钟嘉嘉持续提供军报资源", "周薄森和张耀祖已明确认可其创作能力"],
      "knowledge": ["《军报》采访稿已经过审", "原版视频将继续作为正式军宣内容"],
      "open_threads": ["五天百万粉任务尚未结算", "钟嘉嘉所谓只猜对一半仍未解释"]
    }
  }
}
```

约束:

- 构造事务前运行 `check`,把当前 `state_revision` 原样写入 `expected_state_revision`;若状态已经变化,重新读取 state 并重构事务。
- `context` 的允许字段随子命令不同:`init` 收 `position`、`long_term_constraints`、`active_character_names`、`continuity_risks`、`recent_chapters`、`next_chapter_commitments` 六项;`commit` 只收前四项。`recent_chapters` 与 `next_chapter_commitments` 在 commit 时由工具从当前视图和本章 `delta` 派生,手填会在任何写入前被拒(`context contains unsupported fields: ...`,exit 2)。照 init 示例套 commit 事务是最容易踩的一处。
- `character_snapshots` 中出现的角色视为核心复用角色,必须同时出现在 `character_changes`;已经建立快照的核心角色再次变化时必须提交新快照。
- 角色快照的四个列表不限制条数,只限制单项长度和最终文件总字节:目标 ≤4096 字节,超过警告;硬上限 8192 字节,超过则在任何写入前拒绝。
- 没有快照的角色变化视为临时角色,不建立状态文件;`context.active_character_names` 最多 6 人且必须已有当前快照。
- `context.long_term_constraints` 和 `context.continuity_risks` 是整份提交的当前值。凡是上一版有、本次没有的条目,必须逐条列进 `delta.retired_context_items`,否则工具在任何写入前拒绝——漏写不会被当成删除。实际退役的条目由工具写进本章逐章记录的 `## 本章退役登记`,随后仍可回查。
- 不再复用的核心角色写进 `delta.retired_characters`:工具删除其当前快照与 `角色状态/{角色名}.md`,并在逐章记录留档。同一事务里不能既退役又提交快照,也不能退役仍列在 `context.active_character_names` 的角色。角色阵亡/退场这一章,把变化照写进 `character_changes` 即可,本章退役的角色不必再交一份马上要删的快照,逐章记录仍按核心角色标注。退役只表示不再进入热上下文,正文与逐章记录不受影响。
- 两类退役都只能在 `mode=append` 提交。退役表示「从此刻起离开当前状态」,而修订事务的逐章记录属于被改写的旧章,落在那里会谎报退役发生的章节;`mode=revision` 必须原样重交当前全部上下文条目,需要退役就放到下一次 append。
- `伏笔.md` 只呈现已经埋设过的当前状态。未来规划仍留在大纲。
- `timeline_events.action` 可为 `upsert/delete`。`未揭示` 的 `reveal_chapter` 必须为 `null`;部分/完全揭示只能填写已经发生的实际章节。
- `mode=revision` 时,逐章记录必须重算为修订后该章仍然成立的完整连续性记录;当前角色、伏笔、时间线和上下文则提交受影响对象截至最新已写章的当前值。
- 修订导入截止章内的正文时,会新增或覆盖该章的逐章记录;`imported_through_chapter` 不变。

## 续写状态卡固定格式

`上下文.md` ≤12288 字节,由 state 整份生成,只含以下 7 个顶层区块:

1. `## 当前位置`
2. `## 长期约束`
3. `## 核心角色状态`
4. `## 活跃伏笔`
5. `## 近三章速记`
6. `## 下一章承诺`
7. `## 连贯性风险`

其中活跃角色最多 6 人、活跃伏笔确定性选取最多 8 条、近章只保留 3 章。这些是下一章热上下文容量,不是完整角色状态的容量限制。
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 };
}
scripts/tracking_commit.py
#!/usr/bin/env python3
"""Maintain one structured story state and its deterministic Markdown views.

The language model supplies compact semantic JSON.  This tool validates and
merges that input in memory, renders every derived view, then atomically writes
``_tracking-state.json`` last as the single commit point. Per-project locking
serializes concurrent writers before revision and wordcount checks.
"""

from __future__ import annotations

import argparse
import copy
import importlib.util
import json
import os
import re
import stat
import sys
import tempfile
import time
import unicodedata
from contextlib import contextmanager
from pathlib import Path
from typing import Any


_WORDCOUNT_CORE_PATH = Path(__file__).with_name("wordcount_core.py")
if not _WORDCOUNT_CORE_PATH.is_file():  # pragma: no cover - broken deployment
    raise RuntimeError("TOOL_UNAVAILABLE: wordcount_core.py")
_WORDCOUNT_CORE_SPEC = importlib.util.spec_from_file_location(
    "story_wordcount_core", _WORDCOUNT_CORE_PATH
)
if _WORDCOUNT_CORE_SPEC is None or _WORDCOUNT_CORE_SPEC.loader is None:  # pragma: no cover
    raise RuntimeError("unable to load wordcount core")
wordcount_core = importlib.util.module_from_spec(_WORDCOUNT_CORE_SPEC)
_WORDCOUNT_CORE_SPEC.loader.exec_module(wordcount_core)


INPUT_SCHEMA_VERSION = 1
TRACKING_SCHEMA_VERSION = 4
DELTA_TARGET_BYTES = 1536
DELTA_MAX_BYTES = 3072
CONTEXT_TARGET_BYTES = 8192
CONTEXT_MAX_BYTES = 12288
SNAPSHOT_TARGET_BYTES = 4096
SNAPSHOT_MAX_BYTES = 8192

CONTEXT_HEADINGS = (
    "## 当前位置",
    "## 长期约束",
    "## 核心角色状态",
    "## 活跃伏笔",
    "## 近三章速记",
    "## 下一章承诺",
    "## 连贯性风险",
)
FORESHADOW_STATUSES = ("已埋", "已回收", "已过期", "放弃")
FORESHADOW_IMPORTANCE = ("高", "中", "低")
REVEAL_STATUSES = ("未揭示", "部分揭示", "已揭示")
INVALID_FILE_CHARS = re.compile(r"[<>:\"/\\|?*\x00-\x1f]")
FORESHADOW_ID = re.compile(r"^F\d{3,}$")
EVENT_ID = re.compile(r"^E\d{3,}$")
WINDOWS_RESERVED_NAMES = {
    "CON",
    "PRN",
    "AUX",
    "NUL",
    *(f"COM{index}" for index in range(1, 10)),
    *(f"LPT{index}" for index in range(1, 10)),
}
RETIRED_TRACKING_PATHS = (
    "_tracking-meta.json",
    "阶段摘要.md",
    "角色状态.md",
    "时间线.md",
    "摘要",
    "时间线/事件库.json",
)
RETIRED_ARCHIVE_DIR = "_旧追踪存档"


class TrackingError(ValueError):
    """Expected validation or tracking-state error."""


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


def wordcount_value(function: Any, *args: Any, **kwargs: Any) -> Any:
    try:
        return function(*args, **kwargs)
    except wordcount_core.WordcountError as exc:
        raise TrackingError(str(exc)) from exc


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, *, allow_empty: bool = False, max_bytes: int = 768) -> str:
    require(isinstance(value, str), f"{label} must be a string")
    cleaned = " ".join(value.replace("|", "|").split())
    require(allow_empty or 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 clean_string_list(
    value: object,
    label: str,
    *,
    maximum: int | None = None,
    item_max_bytes: int = 384,
) -> list[str]:
    values = as_list(value, label)
    if maximum is not None:
        require(len(values) <= maximum, f"{label} may contain at most {maximum} items")
    return [clean_text(item, f"{label}[{index}]", max_bytes=item_max_bytes) for index, item in enumerate(values)]


def safe_file_component(value: object, label: str) -> str:
    name = unicodedata.normalize("NFC", clean_text(value, label, max_bytes=180))
    require(not INVALID_FILE_CHARS.search(name), f"{label} contains an invalid filename character")
    require(name not in {".", ".."} and not name.endswith((".", " ")), f"{label} is not a safe filename")
    require(name.split(".", 1)[0].upper() not in WINDOWS_RESERVED_NAMES, f"{label} is reserved on Windows")
    return name


def portable_name_key(name: str) -> str:
    return unicodedata.normalize("NFC", name).casefold()


def byte_size(text: str) -> int:
    return len(text.encode("utf-8"))


def emit(text: str, *, error: bool = False) -> None:
    """Write UTF-8 bytes directly.

    Windows 的文本 stdout 是 cp1252(含中文即 UnicodeEncodeError),stderr 默认
    backslashreplace(中文被转义成反斜杠码位,作者看不懂)。两条路都要绕开。
    """
    stream = sys.stderr if error else sys.stdout
    stream.flush()
    stream.buffer.write((text + "\n").encode("utf-8"))
    stream.buffer.flush()


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


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


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 tracking_root(project: Path) -> Path:
    return project.resolve() / "追踪"


def state_path(project: Path) -> Path:
    return tracking_root(project) / "_tracking-state.json"


@contextmanager
def project_write_lock(project: Path, *, timeout_seconds: float = 10.0):
    tracking = tracking_root(project)
    tracking.mkdir(parents=True, exist_ok=True)
    path = tracking / ".tracking-commit.lock"
    deadline = time.monotonic() + timeout_seconds
    descriptor: int | None = None
    while descriptor is None:
        try:
            descriptor = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
        except FileExistsError:
            if time.monotonic() >= deadline:
                raise TrackingError(
                    "tracking commit lock is busy or stale; retry, or remove 追踪/.tracking-commit.lock after confirming no commit is running"
                )
            time.sleep(0.05)
    try:
        os.write(descriptor, f"pid={os.getpid()}\n".encode("ascii"))
        os.fsync(descriptor)
        yield
    finally:
        os.close(descriptor)
        try:
            path.unlink()
        except FileNotFoundError:
            pass


def delta_path(tracking: Path, chapter: int) -> Path:
    width = max(3, len(str(chapter)))
    return tracking / "逐章记录" / f"第{chapter:0{width}d}章.md"


def find_retired_tracking_paths(tracking: Path) -> list[str]:
    found = [relative for relative in RETIRED_TRACKING_PATHS if (tracking / relative).exists()]
    found.extend(sorted(path.name for path in tracking.glob("基线_截至第*章.md")))
    return found


def require_no_retired_tracking_paths(tracking: Path) -> None:
    found = find_retired_tracking_paths(tracking)
    require(not found, f"retired tracking files are not supported: {', '.join(found)}")


def archive_retired_tracking_paths(tracking: Path) -> list[str]:
    """Move a pre-transaction 追踪/ aside so init can build the current protocol in place.

    Nothing is parsed or converted: the old files are kept verbatim for the author to
    consult, and the new state is reconstructed from the init document alone.
    """
    retired = find_retired_tracking_paths(tracking)
    if not retired:
        return []
    archive = tracking / RETIRED_ARCHIVE_DIR
    for relative in retired:
        require(
            not (archive / relative).exists(),
            f"追踪/{RETIRED_ARCHIVE_DIR}/{relative} already exists; move it away before initializing",
        )
    # 先全量校验再搬运;中断后重跑时已搬走的条目不再出现在待搬列表里,可直接续做。
    for relative in retired:
        target = archive / relative
        target.parent.mkdir(parents=True, exist_ok=True)
        os.replace(tracking / relative, target)
    return retired


def validate_position(value: object, label: str = "context.position") -> dict[str, Any]:
    position = as_mapping(value, label)
    require_known_keys(position, {"volume", "volume_start_chapter", "story_time", "scene"}, label)
    return {
        "volume": safe_file_component(position.get("volume"), f"{label}.volume"),
        "volume_start_chapter": as_int(
            position.get("volume_start_chapter"), f"{label}.volume_start_chapter", minimum=1
        ),
        "story_time": clean_text(position.get("story_time"), f"{label}.story_time", max_bytes=240),
        "scene": clean_text(position.get("scene"), f"{label}.scene", max_bytes=240),
    }


def normalize_snapshot(value: object, label: str) -> dict[str, Any]:
    snapshot = as_mapping(value, label)
    require_known_keys(
        snapshot,
        {"identity", "location", "goal", "state", "abilities_resources", "relationships", "knowledge", "open_threads"},
        label,
    )
    return {
        "identity": clean_text(snapshot.get("identity"), f"{label}.identity", max_bytes=240),
        "location": clean_text(snapshot.get("location"), f"{label}.location", max_bytes=240),
        "goal": clean_text(snapshot.get("goal"), f"{label}.goal", max_bytes=300),
        "state": clean_text(snapshot.get("state"), f"{label}.state", max_bytes=300),
        "abilities_resources": clean_string_list(
            snapshot.get("abilities_resources", []), f"{label}.abilities_resources"
        ),
        "relationships": clean_string_list(snapshot.get("relationships", []), f"{label}.relationships"),
        "knowledge": clean_string_list(snapshot.get("knowledge", []), f"{label}.knowledge"),
        "open_threads": clean_string_list(snapshot.get("open_threads", []), f"{label}.open_threads"),
    }


def normalize_snapshots(value: object, label: str = "character_snapshots") -> dict[str, dict[str, Any]]:
    snapshots = as_mapping(value, label)
    normalized: dict[str, dict[str, Any]] = {}
    portable_names: set[str] = set()
    for raw_name, raw_snapshot in snapshots.items():
        name = safe_file_component(raw_name, f"{label} character name")
        key = portable_name_key(name)
        require(key not in portable_names, f"{label} contains a cross-platform duplicate character {name}")
        portable_names.add(key)
        normalized[name] = normalize_snapshot(raw_snapshot, f"{label}.{name}")
    return normalized


def render_snapshot(name: str, snapshot: dict[str, Any], through_chapter: int, revision: int) -> str:
    def section(title: str, values: list[str]) -> list[str]:
        return [f"## {title}", *(f"- {item}" for item in values or ["无"]), ""]

    lines = [
        f"# {name}|当前状态",
        "",
        f"- 状态修订:{revision}",
        f"- 截至章节:第{through_chapter}章",
        f"- 身份:{snapshot['identity']}",
        f"- 位置:{snapshot['location']}",
        f"- 当前目标:{snapshot['goal']}",
        f"- 身心状态:{snapshot['state']}",
        "",
    ]
    lines.extend(section("能力与资源", snapshot["abilities_resources"]))
    lines.extend(section("关键关系", snapshot["relationships"]))
    lines.extend(section("已知信息", snapshot["knowledge"]))
    lines.extend(section("未结事项", snapshot["open_threads"]))
    payload = "\n".join(lines).rstrip() + "\n"
    require(
        byte_size(payload) <= SNAPSHOT_MAX_BYTES,
        f"character snapshot {name} exceeds hard cap of {SNAPSHOT_MAX_BYTES} bytes",
    )
    return payload


def normalize_foreshadow_change(
    value: object,
    label: str,
    *,
    allow_delete: bool,
    through_chapter: int,
) -> dict[str, Any]:
    row = as_mapping(value, label)
    require_known_keys(
        row,
        {"action", "id", "summary", "planted_chapter", "planned_resolution_chapter", "status", "importance"},
        label,
    )
    action = clean_text(row.get("action", "upsert"), f"{label}.action", max_bytes=24)
    require(action in ({"upsert", "delete"} if allow_delete else {"upsert"}), f"{label}.action is invalid")
    identifier = clean_text(row.get("id"), f"{label}.id", max_bytes=24)
    require(FORESHADOW_ID.fullmatch(identifier) is not None, f"{label}.id must look like F001")
    if action == "delete":
        return {"action": action, "id": identifier}
    planted_chapter = as_int(row.get("planted_chapter"), f"{label}.planted_chapter", minimum=1)
    require(planted_chapter <= through_chapter, f"{label}.planted_chapter cannot be in the future")
    planned_raw = row.get("planned_resolution_chapter")
    planned_chapter = (
        None if planned_raw is None else as_int(planned_raw, f"{label}.planned_resolution_chapter", minimum=1)
    )
    require(
        planned_chapter is None or planned_chapter >= planted_chapter,
        f"{label}.planned_resolution_chapter cannot precede planted_chapter",
    )
    status = clean_text(row.get("status"), f"{label}.status", max_bytes=24)
    importance = clean_text(row.get("importance"), f"{label}.importance", max_bytes=12)
    require(status in FORESHADOW_STATUSES, f"{label}.status must be one of {FORESHADOW_STATUSES}")
    require(importance in FORESHADOW_IMPORTANCE, f"{label}.importance must be one of {FORESHADOW_IMPORTANCE}")
    return {
        "action": action,
        "id": identifier,
        "summary": clean_text(row.get("summary"), f"{label}.summary", max_bytes=360),
        "planted_chapter": planted_chapter,
        "planned_resolution_chapter": planned_chapter,
        "status": status,
        "importance": importance,
    }


def normalize_foreshadow_state(value: object, last_chapter: int) -> dict[str, dict[str, Any]]:
    rows = as_mapping(value, "tracking state.foreshadow")
    normalized: dict[str, dict[str, Any]] = {}
    for raw_identifier, raw_row in rows.items():
        identifier = clean_text(raw_identifier, "tracking state.foreshadow ID", max_bytes=24)
        row = as_mapping(raw_row, f"tracking state.foreshadow.{identifier}")
        require_known_keys(
            row,
            {"id", "summary", "planted_chapter", "planned_resolution_chapter", "status", "importance", "updated_chapter"},
            f"tracking state.foreshadow.{identifier}",
        )
        require(row.get("id") == identifier, f"tracking state.foreshadow.{identifier}.id does not match its key")
        change = normalize_foreshadow_change(
            {
                "action": "upsert",
                **{key: value for key, value in row.items() if key != "updated_chapter"},
            },
            f"tracking state.foreshadow.{identifier}",
            allow_delete=False,
            through_chapter=last_chapter,
        )
        change.pop("action")
        updated = as_int(row.get("updated_chapter"), f"tracking state.foreshadow.{identifier}.updated_chapter", minimum=1)
        require(updated <= last_chapter, f"foreshadow {identifier} updates after current chapter")
        change["updated_chapter"] = updated
        normalized[identifier] = change
    return normalized


def render_foreshadow(rows: dict[str, dict[str, Any]], revision: int) -> str:
    lines = [
        "# 伏笔当前状态",
        "",
        f"> 状态修订:{revision}。每个 ID 只保留一行当前状态;历史变化见 `逐章记录/`。",
        "",
        "| ID | 内容 | 埋设章 | 计划回收章 | 状态 | 重要度 | 最近变更章 |",
        "|---|---|---:|---:|---|---|---:|",
    ]
    for identifier in sorted(rows):
        row = rows[identifier]
        planned = f"第{row['planned_resolution_chapter']}章" if row["planned_resolution_chapter"] else "—"
        lines.append(
            f"| {identifier} | {row['summary']} | 第{row['planted_chapter']}章 | {planned} | "
            f"{row['status']} | {row['importance']} | 第{row['updated_chapter']}章 |"
        )
    return "\n".join(lines) + "\n"


def normalize_timeline_change(
    value: object,
    label: str,
    *,
    allow_delete: bool,
    through_chapter: int,
) -> dict[str, Any]:
    event = as_mapping(value, label)
    require_known_keys(
        event,
        {"action", "id", "story_time", "objective_fact", "reader_knowledge", "reveal_status", "reveal_chapter", "characters"},
        label,
    )
    action = clean_text(event.get("action", "upsert"), f"{label}.action", max_bytes=24)
    require(action in ({"upsert", "delete"} if allow_delete else {"upsert"}), f"{label}.action is invalid")
    identifier = clean_text(event.get("id"), f"{label}.id", max_bytes=24)
    require(EVENT_ID.fullmatch(identifier) is not None, f"{label}.id must look like E001")
    if action == "delete":
        return {"action": action, "id": identifier}
    reveal_status = clean_text(event.get("reveal_status"), f"{label}.reveal_status", max_bytes=24)
    require(reveal_status in REVEAL_STATUSES, f"{label}.reveal_status must be one of {REVEAL_STATUSES}")
    reveal_raw = event.get("reveal_chapter")
    reveal_chapter = None if reveal_raw is None else as_int(reveal_raw, f"{label}.reveal_chapter", minimum=1)
    if reveal_status == "未揭示":
        require(reveal_chapter is None, f"{label} must not put a future reveal chapter in established timeline facts")
    else:
        require(reveal_chapter is not None, f"{label}.reveal_chapter is required once revealed")
        require(reveal_chapter <= through_chapter, f"{label}.reveal_chapter cannot be in the future")
    return {
        "action": action,
        "id": identifier,
        "story_time": clean_text(event.get("story_time"), f"{label}.story_time", max_bytes=240),
        "objective_fact": clean_text(event.get("objective_fact"), f"{label}.objective_fact", max_bytes=480),
        "reader_knowledge": clean_text(event.get("reader_knowledge"), f"{label}.reader_knowledge", max_bytes=480),
        "reveal_status": reveal_status,
        "reveal_chapter": reveal_chapter,
        "characters": clean_string_list(event.get("characters", []), f"{label}.characters", maximum=12, item_max_bytes=120),
    }


def normalize_timeline_state(value: object, last_chapter: int) -> dict[str, dict[str, Any]]:
    events = as_mapping(value, "tracking state.timeline")
    normalized: dict[str, dict[str, Any]] = {}
    for raw_identifier, raw_event in events.items():
        identifier = clean_text(raw_identifier, "tracking state.timeline ID", max_bytes=24)
        event = as_mapping(raw_event, f"tracking state.timeline.{identifier}")
        require_known_keys(
            event,
            {
                "id", "story_time", "objective_fact", "reader_knowledge", "reveal_status", "reveal_chapter",
                "characters", "first_recorded_chapter", "updated_chapter",
            },
            f"tracking state.timeline.{identifier}",
        )
        require(event.get("id") == identifier, f"tracking state.timeline.{identifier}.id does not match its key")
        change = normalize_timeline_change(
            {
                "action": "upsert",
                **{
                    key: value
                    for key, value in event.items()
                    if key not in {"first_recorded_chapter", "updated_chapter"}
                },
            },
            f"tracking state.timeline.{identifier}",
            allow_delete=False,
            through_chapter=last_chapter,
        )
        change.pop("action")
        first = as_int(event.get("first_recorded_chapter"), f"tracking state.timeline.{identifier}.first_recorded_chapter", minimum=1)
        updated = as_int(event.get("updated_chapter"), f"tracking state.timeline.{identifier}.updated_chapter", minimum=1)
        require(first <= last_chapter, f"timeline event {identifier} starts after current chapter")
        require(updated <= last_chapter, f"timeline event {identifier} updates after current chapter")
        change["first_recorded_chapter"] = first
        change["updated_chapter"] = updated
        normalized[identifier] = change
    return normalized


def render_timeline_views(events: dict[str, dict[str, Any]], revision: int) -> tuple[str, str]:
    author_lines = [
        "# 作者真相时间线",
        "",
        f"> 状态修订:{revision}。客观事实与读者认知的权威对照;未来揭示计划仍留在大纲。",
        "",
        "| ID | 首次登记章 | 故事时间 | 客观事实 | 读者当前认知 | 揭示状态 | 实际揭示章 |",
        "|---|---:|---|---|---|---|---:|",
    ]
    reader_lines = [
        "# 读者已知时间线",
        "",
        f"> 状态修订:{revision}。只呈现读者截至当前章节已经知道或相信的内容,不泄露作者侧客观真相。",
        "",
        "| ID | 读者当前认知 | 认知截至章 |",
        "|---|---|---:|",
    ]
    for identifier in sorted(events):
        event = events[identifier]
        reveal = f"第{event['reveal_chapter']}章" if event.get("reveal_chapter") else "—"
        characters = "、".join(event.get("characters", []))
        objective = event["objective_fact"] + (f"(涉及:{characters})" if characters else "")
        author_lines.append(
            f"| {identifier} | 第{event['first_recorded_chapter']}章 | {event['story_time']} | {objective} | "
            f"{event['reader_knowledge']} | {event['reveal_status']} | {reveal} |"
        )
        reader_lines.append(f"| {identifier} | {event['reader_knowledge']} | 第{event['updated_chapter']}章 |")
    return "\n".join(author_lines) + "\n", "\n".join(reader_lines) + "\n"


def validate_context_input(value: object, *, include_initial_fields: bool) -> dict[str, Any]:
    context = as_mapping(value, "context")
    allowed = {"position", "long_term_constraints", "active_character_names", "continuity_risks"}
    if include_initial_fields:
        allowed.update({"recent_chapters", "next_chapter_commitments"})
    require_known_keys(context, allowed, "context")
    normalized: dict[str, Any] = {
        "position": validate_position(context.get("position")),
        "long_term_constraints": clean_string_list(
            context.get("long_term_constraints", []), "context.long_term_constraints", maximum=6
        ),
        "active_character_names": [
            safe_file_component(name, f"context.active_character_names[{index}]")
            for index, name in enumerate(as_list(context.get("active_character_names", []), "context.active_character_names"))
        ],
        "continuity_risks": clean_string_list(
            context.get("continuity_risks", []), "context.continuity_risks", maximum=5
        ),
    }
    require(len(normalized["active_character_names"]) <= 6, "context.active_character_names may contain at most 6 names")
    require(
        len({portable_name_key(name) for name in normalized["active_character_names"]})
        == len(normalized["active_character_names"]),
        "context.active_character_names contains cross-platform duplicates",
    )
    if include_initial_fields:
        recent: list[dict[str, Any]] = []
        for index, raw_item in enumerate(as_list(context.get("recent_chapters", []), "context.recent_chapters")):
            item = as_mapping(raw_item, f"context.recent_chapters[{index}]")
            require_known_keys(item, {"chapter", "summary"}, f"context.recent_chapters[{index}]")
            recent.append(
                {
                    "chapter": as_int(item.get("chapter"), f"context.recent_chapters[{index}].chapter", minimum=1),
                    "summary": clean_text(item.get("summary"), f"context.recent_chapters[{index}].summary", max_bytes=360),
                }
            )
        require(len(recent) <= 3, "context.recent_chapters may contain at most 3 items")
        normalized["recent_chapters"] = recent
        normalized["next_chapter_commitments"] = clean_string_list(
            context.get("next_chapter_commitments", []), "context.next_chapter_commitments", maximum=5
        )
    return normalized


def active_foreshadow_lines(rows: dict[str, dict[str, Any]]) -> list[str]:
    importance = {value: index for index, value in enumerate(FORESHADOW_IMPORTANCE)}
    candidates = [row for row in rows.values() if row["status"] == "已埋"]
    candidates.sort(
        key=lambda row: (importance[row["importance"]], row["planned_resolution_chapter"] or 10**12, row["id"])
    )
    result = []
    for row in candidates[:8]:
        planned = f"第{row['planned_resolution_chapter']}章" if row["planned_resolution_chapter"] else "回收章未定"
        result.append(f"{row['id']}|{row['summary']}|埋第{row['planted_chapter']}章|{planned}|{row['importance']}")
    return result


def render_context(state: dict[str, Any]) -> str:
    context = state["context"]
    position = context["position"]
    current_chapter = (
        "尚未开篇" if state["last_committed_chapter"] == 0 else f"第{state['last_committed_chapter']}章"
    )
    character_lines = [
        f"{name}|{state['characters'][name]['identity']}|{state['characters'][name]['state']}|"
        f"目标:{state['characters'][name]['goal']}"
        for name in context["active_character_names"]
    ]
    sections: list[tuple[str, list[str]]] = [
        (
            "## 当前位置",
            [
                f"当前章:{current_chapter}",
                f"卷:{position['volume']}(始于第{position['volume_start_chapter']}章)",
                f"故事时间:{position['story_time']}",
                f"场景:{position['scene']}",
            ],
        ),
        ("## 长期约束", context["long_term_constraints"]),
        ("## 核心角色状态", character_lines),
        ("## 活跃伏笔", active_foreshadow_lines(state["foreshadow"])),
        ("## 近三章速记", [f"第{item['chapter']}章|{item['summary']}" for item in context["recent_chapters"]]),
        ("## 下一章承诺", context["next_chapter_commitments"]),
        ("## 连贯性风险", context["continuity_risks"]),
    ]
    lines = [
        f"# 写作连续性上下文 — {state['book_title']}",
        "",
        f"> 状态修订:{state['state_revision']}。截至当前章的续写状态卡,只放下一章真正需要的连续性状态。",
        "",
    ]
    for heading, values in sections:
        lines.append(heading)
        lines.extend(f"- {value}" for value in values or ["无"])
        lines.append("")
    payload = "\n".join(lines).rstrip() + "\n"
    headings = tuple(line for line in payload.splitlines() if line.startswith("## "))
    require(headings == CONTEXT_HEADINGS, "generated context headings do not match the seven-section schema")
    require(byte_size(payload) <= CONTEXT_MAX_BYTES, f"hot context exceeds {CONTEXT_MAX_BYTES} bytes")
    return payload


def normalize_delta(
    value: object,
    *,
    through_chapter: int,
    snapshots: dict[str, dict[str, Any]],
    existing_core_names: dict[str, str],
) -> dict[str, Any]:
    delta = as_mapping(value, "delta")
    require_known_keys(
        delta,
        {
            "result", "character_changes", "foreshadow_changes", "timeline_events", "constraints",
            "next_chapter_commitments", "retired_context_items", "retired_characters",
        },
        "delta",
    )
    retired_characters = [
        safe_file_component(name, f"delta.retired_characters[{index}]")
        for index, name in enumerate(as_list(delta.get("retired_characters", []), "delta.retired_characters"))
    ]
    retired_keys = [portable_name_key(name) for name in retired_characters]
    require(len(retired_keys) == len(set(retired_keys)), "delta.retired_characters contains duplicate characters")
    retiring = set(retired_keys)
    character_changes: list[dict[str, Any]] = []
    for index, raw_change in enumerate(as_list(delta.get("character_changes", []), "delta.character_changes")):
        change = as_mapping(raw_change, f"delta.character_changes[{index}]")
        require_known_keys(change, {"name", "change"}, f"delta.character_changes[{index}]")
        name = safe_file_component(change.get("name"), f"delta.character_changes[{index}].name")
        existing = existing_core_names.get(portable_name_key(name))
        is_core = name in snapshots or existing is not None
        # 本章退役的角色记录最后一次变化即可,不必再交一份马上要删的快照。
        require(
            not is_core or name in snapshots or portable_name_key(name) in retiring,
            f"core character {name} changed but has no current snapshot",
        )
        character_changes.append(
            {"name": name, "change": clean_text(change.get("change"), f"delta.character_changes[{index}].change", max_bytes=360)}
        )
    character_keys = [portable_name_key(item["name"]) for item in character_changes]
    require(len(character_keys) == len(set(character_keys)), "delta.character_changes contains duplicate characters")
    foreshadow_changes = [
        normalize_foreshadow_change(
            raw, f"delta.foreshadow_changes[{index}]", allow_delete=True, through_chapter=through_chapter
        )
        for index, raw in enumerate(as_list(delta.get("foreshadow_changes", []), "delta.foreshadow_changes"))
    ]
    timeline_events = [
        normalize_timeline_change(
            raw, f"delta.timeline_events[{index}]", allow_delete=True, through_chapter=through_chapter
        )
        for index, raw in enumerate(as_list(delta.get("timeline_events", []), "delta.timeline_events"))
    ]
    require(
        len({item["id"] for item in foreshadow_changes}) == len(foreshadow_changes),
        "delta.foreshadow_changes contains duplicate IDs",
    )
    require(
        len({item["id"] for item in timeline_events}) == len(timeline_events),
        "delta.timeline_events contains duplicate IDs",
    )
    require(
        set(snapshots).issubset({item["name"] for item in character_changes}),
        "character_snapshots must contain exactly the core characters changed by this transaction",
    )
    return {
        "result": clean_text(delta.get("result"), "delta.result", max_bytes=480),
        "character_changes": character_changes,
        "foreshadow_changes": foreshadow_changes,
        "timeline_events": timeline_events,
        "constraints": clean_string_list(delta.get("constraints", []), "delta.constraints", maximum=6),
        "next_chapter_commitments": clean_string_list(
            delta.get("next_chapter_commitments", []), "delta.next_chapter_commitments", maximum=5
        ),
        "retired_context_items": clean_string_list(
            delta.get("retired_context_items", []), "delta.retired_context_items", maximum=11
        ),
        "retired_characters": retired_characters,
    }


def render_delta(chapter: int, title: str, delta: dict[str, Any], core_names: set[str]) -> str:
    lines = [
        f"# 第{chapter:03d}章 · {title}",
        f"- 结果:{delta['result']}",
        "- 下一章承诺:" + (";".join(delta["next_chapter_commitments"]) or "无"),
        "",
        "## 角色变化",
    ]
    lines.extend(
        f"- {item['name']}|{'核心' if item['name'] in core_names else '临时'}|{item['change']}"
        for item in delta["character_changes"]
    )
    if not delta["character_changes"]:
        lines.append("- 无")
    lines.extend(["", "## 伏笔变化"])
    for item in delta["foreshadow_changes"]:
        if item["action"] == "delete":
            lines.append(f"- {item['id']}|删除当前登记")
        else:
            planned = f"第{item['planned_resolution_chapter']}章" if item["planned_resolution_chapter"] else "未定"
            lines.append(f"- {item['id']}|{item['status']}|{item['summary']}|回收{planned}")
    if not delta["foreshadow_changes"]:
        lines.append("- 无")
    lines.extend(["", "## 时间与揭示"])
    for item in delta["timeline_events"]:
        if item["action"] == "delete":
            lines.append(f"- {item['id']}|删除当前登记")
        else:
            lines.append(
                f"- {item['id']}|{item['story_time']}|事实:{item['objective_fact']}|"
                f"读者:{item['reader_knowledge']}|{item['reveal_status']}"
            )
    if not delta["timeline_events"]:
        lines.append("- 无")
    lines.extend(["", "## 连贯性约束"])
    lines.extend(f"- {item}" for item in delta["constraints"])
    if not delta["constraints"]:
        lines.append("- 无")
    retired = delta.get("retired_context_items", []) + [
        f"角色状态:{name}" for name in delta.get("retired_characters", [])
    ]
    if retired:
        # 退役条目在此留档,续写状态卡收缩后仍可回查当初撤下了什么。
        lines.extend(["", "## 本章退役登记"])
        lines.extend(f"- {item}" for item in retired)
    payload = "\n".join(lines) + "\n"
    size = byte_size(payload)
    require(size <= DELTA_MAX_BYTES, f"chapter delta is {size} bytes; hard cap is {DELTA_MAX_BYTES}")
    return payload


def normalize_wordcount_records(value: object, last_chapter: int) -> dict[str, dict[str, Any]]:
    records = as_mapping(value, "tracking state.wordcount_records")
    normalized: dict[str, dict[str, Any]] = {}
    for raw_chapter, raw_record in records.items():
        require(isinstance(raw_chapter, str) and re.fullmatch(r"[1-9]\d*", raw_chapter) is not None,
                "wordcount record chapter key is invalid")
        chapter = int(raw_chapter)
        require(chapter <= last_chapter, "wordcount record exceeds last committed chapter")
        normalized[raw_chapter] = wordcount_value(wordcount_core.normalize_wordcount_record, raw_record)
    return normalized


def normalize_state(document: object) -> dict[str, Any]:
    root = as_mapping(document, "tracking state")
    require_known_keys(
        root,
        {
            "schema_version", "book_title", "last_committed_chapter", "imported_through_chapter",
            "state_revision", "context", "characters", "foreshadow", "timeline",
            "wordcount_records",
        },
        "tracking state",
    )
    require(root.get("schema_version") == TRACKING_SCHEMA_VERSION, "tracking state schema is unsupported")
    last_chapter = as_int(root.get("last_committed_chapter"), "tracking state.last_committed_chapter")
    imported_through = as_int(root.get("imported_through_chapter"), "tracking state.imported_through_chapter")
    require(imported_through <= last_chapter, "imported chapter cutoff exceeds current chapter")
    context = validate_context_input(root.get("context"), include_initial_fields=True)
    require(
        context["position"]["volume_start_chapter"] <= max(1, last_chapter),
        "context.position.volume_start_chapter is after the current writing position",
    )
    recent_numbers = [item["chapter"] for item in context["recent_chapters"]]
    require(recent_numbers == sorted(recent_numbers), "context.recent_chapters must be ordered")
    require(len(recent_numbers) == len(set(recent_numbers)), "context.recent_chapters contains duplicates")
    require(all(chapter <= last_chapter for chapter in recent_numbers), "context.recent_chapters cannot include future chapters")
    characters = normalize_snapshots(root.get("characters", {}), "tracking state.characters")
    for name in context["active_character_names"]:
        require(name in characters, f"active core character {name} has no current snapshot")
    foreshadow = normalize_foreshadow_state(root.get("foreshadow", {}), last_chapter)
    timeline = normalize_timeline_state(root.get("timeline", {}), last_chapter)
    if last_chapter == 0:
        require(not foreshadow, "a chapter-0 project cannot have planted foreshadow facts")
        require(not timeline, "a chapter-0 project cannot have established timeline facts")
    state_revision = as_int(root.get("state_revision"), "tracking state.state_revision")
    wordcount_records = normalize_wordcount_records(root.get("wordcount_records", {}), last_chapter)
    return {
        "schema_version": TRACKING_SCHEMA_VERSION,
        "book_title": clean_text(root.get("book_title"), "tracking state.book_title", max_bytes=240),
        "last_committed_chapter": last_chapter,
        "imported_through_chapter": imported_through,
        "state_revision": state_revision,
        "context": context,
        "characters": characters,
        "foreshadow": foreshadow,
        "timeline": timeline,
        "wordcount_records": wordcount_records,
    }


def load_state(project: Path) -> dict[str, Any]:
    path = state_path(project)
    require(path.exists(), "tracking state is missing; run init first")
    return normalize_state(read_json(path))


def normalize_initial_document(document: object) -> dict[str, Any]:
    root = as_mapping(document, "init input")
    require_known_keys(
        root,
        {
            "schema_version", "book_title", "last_chapter", "context", "character_snapshots",
            "foreshadow", "timeline_events",
        },
        "init input",
    )
    require(root.get("schema_version") == INPUT_SCHEMA_VERSION, "init input schema_version is unsupported")
    last_chapter = as_int(root.get("last_chapter"), "last_chapter")
    context = validate_context_input(root.get("context"), include_initial_fields=True)
    snapshots = normalize_snapshots(root.get("character_snapshots", {}))
    foreshadow: dict[str, dict[str, Any]] = {}
    for index, raw_row in enumerate(as_list(root.get("foreshadow", []), "foreshadow")):
        row = normalize_foreshadow_change(
            raw_row, f"foreshadow[{index}]", allow_delete=False, through_chapter=last_chapter
        )
        require(row["id"] not in foreshadow, f"duplicate foreshadow ID {row['id']}")
        row.pop("action")
        row["updated_chapter"] = max(1, last_chapter)
        foreshadow[row["id"]] = row
    timeline: dict[str, dict[str, Any]] = {}
    for index, raw_event in enumerate(as_list(root.get("timeline_events", []), "timeline_events")):
        event = normalize_timeline_change(
            raw_event, f"timeline_events[{index}]", allow_delete=False, through_chapter=last_chapter
        )
        require(event["id"] not in timeline, f"duplicate timeline event ID {event['id']}")
        event.pop("action")
        event["first_recorded_chapter"] = max(1, last_chapter)
        event["updated_chapter"] = max(1, last_chapter)
        timeline[event["id"]] = event
    return normalize_state(
        {
            "schema_version": TRACKING_SCHEMA_VERSION,
            "book_title": clean_text(root.get("book_title"), "book_title", max_bytes=240),
            "last_committed_chapter": last_chapter,
            "imported_through_chapter": last_chapter,
            "state_revision": 0,
            "context": context,
            "characters": snapshots,
            "foreshadow": foreshadow,
            "timeline": timeline,
            "wordcount_records": {},
        }
    )


def normalize_transaction(project: Path, state: dict[str, Any], document: object) -> dict[str, Any]:
    root = as_mapping(document, "transaction")
    require_known_keys(
        root,
        {
            "schema_version", "mode", "chapter", "chapter_title", "expected_state_revision",
            "delta", "context", "character_snapshots", "wordcount",
        },
        "transaction",
    )
    require(root.get("schema_version") == INPUT_SCHEMA_VERSION, "transaction schema_version is unsupported")
    mode = clean_text(root.get("mode"), "mode", max_bytes=24)
    require(mode in {"append", "revision"}, "mode must be append or revision")
    chapter = as_int(root.get("chapter"), "chapter", minimum=1)
    expected_revision = as_int(root.get("expected_state_revision"), "expected_state_revision")
    wordcount_input = root.get("wordcount")
    require(expected_revision == state["state_revision"], "tracking state changed since this transaction was prepared")
    last = state["last_committed_chapter"]
    if mode == "append":
        require(chapter == last + 1, f"append chapter must be {last + 1}, got {chapter}")
    else:
        require(chapter <= last, f"cannot revise unwritten chapter {chapter}; last committed chapter is {last}")
    context = validate_context_input(root.get("context"), include_initial_fields=False)
    snapshots = normalize_snapshots(root.get("character_snapshots", {}))
    existing_names = {portable_name_key(name): name for name in state["characters"]}
    for name in snapshots:
        existing = existing_names.get(portable_name_key(name))
        require(existing is None or existing == name, f"character {name} conflicts with existing character {existing}")
    through_chapter = chapter if mode == "append" else last
    delta = normalize_delta(
        root.get("delta"),
        through_chapter=through_chapter,
        snapshots=snapshots,
        existing_core_names=existing_names,
    )
    wordcount = None
    if wordcount_input is not None:
        wordcount = wordcount_value(
            wordcount_core.validate_current_wordcount_record, project, chapter, wordcount_input
        )
    return {
        "mode": mode,
        "chapter": chapter,
        "title": clean_text(root.get("chapter_title"), "chapter_title", max_bytes=240),
        "delta": delta,
        "context": context,
        "snapshots": snapshots,
        "wordcount": wordcount,
    }


def checkpoint_record(
    change: dict[str, Any], chapter: int, previous: dict[str, Any] | None, *, keep_first_chapter: bool = False
) -> dict[str, Any]:
    current = {key: value for key, value in change.items() if key != "action"}
    current["updated_chapter"] = max(previous["updated_chapter"] if previous else chapter, chapter)
    if keep_first_chapter:
        current["first_recorded_chapter"] = previous["first_recorded_chapter"] if previous else chapter
    return current


def merge_transaction(state: dict[str, Any], transaction: dict[str, Any]) -> dict[str, Any]:
    next_state = copy.deepcopy(state)
    chapter = transaction["chapter"]
    if transaction["mode"] == "append":
        next_state["last_committed_chapter"] = chapter
    next_state["state_revision"] += 1
    next_state["characters"].update(transaction["snapshots"])
    if transaction["wordcount"] is not None:
        next_state["wordcount_records"][str(chapter)] = transaction["wordcount"]

    next_context = transaction["context"]
    # 退役说的是「从此刻起离开当前状态」,只有 append 的逐章记录代表此刻;
    # 修订记录属于被改写的旧章,落在那里会谎报退役发生的章节。
    is_revision = transaction["mode"] == "revision"
    require(
        not (is_revision and transaction["delta"]["retired_characters"]),
        "retired_characters must be committed in an append transaction, not a revision",
    )
    for name in transaction["delta"]["retired_characters"]:
        require(name in next_state["characters"], f"retired character {name} has no current snapshot")
        require(
            name not in transaction["snapshots"],
            f"character {name} cannot be retired and updated in the same transaction",
        )
        require(
            name not in next_context["active_character_names"],
            f"retired character {name} is still listed in context.active_character_names",
        )
        next_state["characters"].pop(name)

    # 上下文条目是整份提交的;漏写会静默丢历史裁定,因此掉落必须显式声明。
    previous_items = set(state["context"]["long_term_constraints"]) | set(state["context"]["continuity_risks"])
    dropped = previous_items - (set(next_context["long_term_constraints"]) | set(next_context["continuity_risks"]))
    require(
        not (is_revision and dropped),
        "a revision must resubmit every current context item; retire them in an append transaction instead: "
        + ";".join(sorted(dropped)),
    )
    undeclared = sorted(dropped - set(transaction["delta"]["retired_context_items"]))
    require(
        not undeclared,
        "context items were dropped without being declared in delta.retired_context_items: "
        + ";".join(undeclared),
    )
    transaction["delta"]["retired_context_items"] = sorted(dropped)

    for change in transaction["delta"]["foreshadow_changes"]:
        if change["action"] == "delete":
            next_state["foreshadow"].pop(change["id"], None)
        else:
            next_state["foreshadow"][change["id"]] = checkpoint_record(
                change, chapter, next_state["foreshadow"].get(change["id"])
            )
    for change in transaction["delta"]["timeline_events"]:
        if change["action"] == "delete":
            next_state["timeline"].pop(change["id"], None)
        else:
            next_state["timeline"][change["id"]] = checkpoint_record(
                change, chapter, next_state["timeline"].get(change["id"]), keep_first_chapter=True
            )

    recent_by_chapter = {item["chapter"]: item for item in state["context"]["recent_chapters"]}
    if chapter in recent_by_chapter or transaction["mode"] == "append":
        recent_by_chapter[chapter] = {"chapter": chapter, "summary": transaction["delta"]["result"]}
    recent = sorted(recent_by_chapter.values(), key=lambda item: item["chapter"])[-3:]
    current_last = next_state["last_committed_chapter"]
    next_commitments = (
        transaction["delta"]["next_chapter_commitments"]
        if transaction["mode"] == "append" or chapter == current_last
        else state["context"]["next_chapter_commitments"]
    )
    next_state["context"] = {
        **next_context,
        "recent_chapters": recent,
        "next_chapter_commitments": next_commitments,
    }
    return normalize_state(next_state)


def render_views(state: dict[str, Any]) -> dict[str, str]:
    revision = state["state_revision"]
    views = {
        "上下文.md": render_context(state),
        "伏笔.md": render_foreshadow(state["foreshadow"], revision),
    }
    author, reader = render_timeline_views(state["timeline"], revision)
    views["时间线/作者真相.md"] = author
    views["时间线/读者已知.md"] = reader
    for name, snapshot in state["characters"].items():
        views[f"角色状态/{name}.md"] = render_snapshot(
            name, snapshot, state["last_committed_chapter"], revision
        )
    return views


def write_views(tracking: Path, views: dict[str, str]) -> None:
    # 上下文携带 next revision,先写它;任何后续失败都会让 hook/check 发现
    # 上下文 revision 与最后提交的 _tracking-state.json 不一致。
    write_if_changed(tracking / "上下文.md", views["上下文.md"])
    for relative in sorted(path for path in views if path != "上下文.md"):
        write_if_changed(tracking / relative, views[relative])
    expected_character_files = {
        Path(relative).name for relative in views if relative.startswith("角色状态/")
    }
    character_dir = tracking / "角色状态"
    character_dir.mkdir(parents=True, exist_ok=True)
    for path in character_dir.glob("*.md"):
        if path.name not in expected_character_files:
            path.unlink()


def warn_sizes(views: dict[str, str], delta_payload: str | None = None) -> None:
    if delta_payload is not None and byte_size(delta_payload) > DELTA_TARGET_BYTES:
        emit(
            f"WARNING: chapter delta is {byte_size(delta_payload)} bytes; target is <= {DELTA_TARGET_BYTES}",
            error=True,
        )
    context_size = byte_size(views["上下文.md"])
    if context_size > CONTEXT_TARGET_BYTES:
        emit(f"WARNING: hot context is {context_size} bytes; target is <= {CONTEXT_TARGET_BYTES}", error=True)
    for relative, payload in views.items():
        if not relative.startswith("角色状态/"):
            continue
        size = byte_size(payload)
        if size > SNAPSHOT_TARGET_BYTES:
            emit(
                f"WARNING: character snapshot {Path(relative).stem} is {size} bytes; target is <= {SNAPSHOT_TARGET_BYTES}",
                error=True,
            )


def _initialize_locked(project: Path, document: object) -> dict[str, Any]:
    tracking = tracking_root(project)
    require(not state_path(project).exists(), "tracking state already exists; init never overwrites project state")
    state = normalize_initial_document(document)
    views = render_views(state)
    state_payload = json_payload(state)

    # 输入全部校验通过后才动用户文件,失败的 init 不会挪走任何东西。
    archived = archive_retired_tracking_paths(tracking)
    for directory in (tracking / "逐章记录", tracking / "角色状态", tracking / "时间线"):
        directory.mkdir(parents=True, exist_ok=True)
    write_views(tracking, views)
    atomic_write_text(state_path(project), state_payload)
    warn_sizes(views)
    if archived:
        emit(
            f"NOTE: 旧追踪结构已原样移入 追踪/{RETIRED_ARCHIVE_DIR}/:{', '.join(archived)};"
            "当前状态以本次 init 输入为准,旧文件不参与解析。",
            error=True,
        )
    return state


def initialize(project: Path, document: object) -> dict[str, Any]:
    with project_write_lock(project):
        return _initialize_locked(project, document)


def _apply_transaction_locked(project: Path, document: object) -> dict[str, Any]:
    tracking = tracking_root(project)
    require_no_retired_tracking_paths(tracking)
    state = load_state(project)
    transaction = normalize_transaction(project, state, document)
    next_state = merge_transaction(state, transaction)

    delta_payload = render_delta(
        transaction["chapter"],
        transaction["title"],
        transaction["delta"],
        # 本章退役的角色在 next_state 里已被删除,但本章记录里仍应标为核心。
        set(next_state["characters"]) | set(transaction["delta"]["retired_characters"]),
    )
    views = render_views(next_state)
    next_state_payload = json_payload(next_state)
    path = delta_path(tracking, transaction["chapter"])
    if transaction["mode"] == "append" and path.exists():
        require(
            path.read_text(encoding="utf-8") == delta_payload,
            f"chapter delta {transaction['chapter']} already exists with different content",
        )

    write_if_changed(path, delta_payload)
    write_views(tracking, views)
    # 唯一权威文件最后落盘;在此之前失败可用同一事务直接重跑。
    atomic_write_text(state_path(project), next_state_payload)
    warn_sizes(views, delta_payload)
    return next_state


def apply_transaction(project: Path, document: object) -> dict[str, Any]:
    with project_write_lock(project):
        return _apply_transaction_locked(project, document)


def check_project(project: Path) -> dict[str, Any]:
    tracking = tracking_root(project)
    require_no_retired_tracking_paths(tracking)
    state = load_state(project)
    last_chapter = state["last_committed_chapter"]
    required_delta_start = state["imported_through_chapter"] + 1
    for chapter in range(required_delta_start, last_chapter + 1):
        require(delta_path(tracking, chapter).exists(), f"chapter delta {chapter} is missing")
    for path in (tracking / "逐章记录").glob("第*章.md"):
        match = re.fullmatch(r"第(\d+)章\.md", path.name)
        require(match is not None, f"chapter delta has an invalid filename: {path.name}")
        chapter = as_int(int(match.group(1)), f"chapter delta {path.name}", minimum=1)
        require(path == delta_path(tracking, chapter), f"chapter delta {chapter} filename is not canonical")
        require(chapter <= last_chapter, f"chapter delta {chapter} exceeds last_committed_chapter")
        require(path.stat().st_size <= DELTA_MAX_BYTES, f"chapter delta {chapter} exceeds {DELTA_MAX_BYTES} bytes")

    expected_views = render_views(state)
    for relative, expected in expected_views.items():
        path = tracking / relative
        require(path.exists(), f"derived view is missing: {relative}")
        require(
            path.read_text(encoding="utf-8") == expected,
            f"derived view differs from _tracking-state.json: {relative}",
        )
    expected_character_files = {
        Path(relative).name for relative in expected_views if relative.startswith("角色状态/")
    }
    actual_character_files = {path.name for path in (tracking / "角色状态").glob("*.md")}
    require(actual_character_files == expected_character_files, "character snapshot files differ from tracking state")
    return state


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description=__doc__)
    subparsers = parser.add_subparsers(dest="command", required=True)
    for command in ("init", "commit"):
        subparser = subparsers.add_parser(command)
        subparser.add_argument("--project", type=Path, required=True, help="book project root containing 追踪/")
        subparser.add_argument("--input", type=Path, required=True, help="UTF-8 JSON input document")
    check_parser = subparsers.add_parser("check")
    check_parser.add_argument("--project", type=Path, required=True, help="book project root containing 追踪/")
    return parser


def main() -> int:
    args = build_parser().parse_args()
    try:
        if args.command == "init":
            result = initialize(args.project, read_json(args.input))
        elif args.command == "commit":
            result = apply_transaction(args.project, read_json(args.input))
        else:
            result = check_project(args.project)
    except (TrackingError, OSError, UnicodeError) as exc:
        emit(f"ERROR: {exc}", error=True)
        return 2
    emit(
        json.dumps(
            {
                "last_committed_chapter": result["last_committed_chapter"],
                "state_revision": result["state_revision"],
            },
            ensure_ascii=False,
        )
    )
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
scripts/wordcount_core.py
#!/usr/bin/env python3
"""Small deterministic wordcount core shared by storyctl and tracking."""

from __future__ import annotations

import hashlib
import re
from pathlib import Path
from typing import Any


WORDCOUNT_SCHEMA = "story-wordcount-result/v1"
MEASUREMENT_SCHEMA = "story-wordcount-measurement/v1"
CHECKPOINT_SCHEMA = "story-wordcount-checkpoint/v1"
METRIC = "visible_chars_v1"
RESOLUTIONS = frozenset({"within_user_band", "accepted_current_length"})

_WHITE_SPACE_CODEPOINTS = frozenset(
    [*range(0x0009, 0x000E)]
    + [
        0x0020, 0x0085, 0x00A0, 0x1680, *range(0x2000, 0x200B),
        0x2028, 0x2029, 0x202F, 0x205F, 0x3000,
    ]
)
_FRONTMATTER_KEY_RE = re.compile(r"^[A-Za-z_\u3400-\u9FFF][^:\n]{0,80}:[ \t]*(?:.*)$")
_LEADING_BLANK_RE = re.compile(r"^[\u0009\u0020\u3000]*$")
_ATX_HEADING_RE = re.compile(r"^[\u0009\u0020]{0,3}#{1,6}[\u0009\u0020]+\S")
_POSITIVE_INTEGER_RE = re.compile(r"^[1-9]\d*$")
_TARGET_LINE_RE = re.compile(r"^[ \t>*-]*字数目标[ \t]*[::][ \t]*([1-9]\d*)[ \t]*(?:字)?[ \t]*$", re.MULTILINE)
_METRIC_LINE_RE = re.compile(r"^[ \t>*-]*字数口径[ \t]*[::][ \t]*([A-Za-z0-9_-]+)[ \t]*$", re.MULTILINE)


class WordcountError(ValueError):
    """Expected deterministic wordcount contract failure."""


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


def normalize_newlines(value: str) -> str:
    return value.replace("\r\n", "\n").replace("\r", "\n")


def strip_recognizable_frontmatter(value: str) -> str:
    lines = value.split("\n")
    if not lines or lines[0] != "---":
        return value
    closing = next((index for index in range(1, min(len(lines), 201)) if lines[index] in {"---", "..."}), -1)
    if closing < 2 or not any(_FRONTMATTER_KEY_RE.match(line) for line in lines[1:closing]):
        return value
    return "\n".join(lines[closing + 1 :])


def visible_body(value: str) -> str:
    if not isinstance(value, str):
        raise TypeError("body must be a string")
    text = normalize_newlines(value)
    if text.startswith("\ufeff"):
        text = text[1:]
    lines = strip_recognizable_frontmatter(text).split("\n")
    while lines and _LEADING_BLANK_RE.match(lines[0]):
        lines.pop(0)
    if lines and _ATX_HEADING_RE.match(lines[0]):
        lines.pop(0)
    return "\n".join(lines)


def count_visible_chars(value: str) -> int:
    return sum(ord(character) not in _WHITE_SPACE_CODEPOINTS for character in visible_body(value))


def parse_target(value: Any) -> int:
    raw = str(value if value is not None else "")
    if not _POSITIVE_INTEGER_RE.fullmatch(raw):
        raise WordcountError("target must be a positive integer")
    target = int(raw)
    require(target <= 9_007_199_254_740_991, "target exceeds Number.MAX_SAFE_INTEGER")
    return target


def compute_wordcount_bands(value: Any) -> dict[str, dict[str, int]]:
    target = parse_target(value)
    return {
        "internal": {"min": (target * 88 + 99) // 100, "max": target * 112 // 100},
        "user": {"min": (target * 85 + 99) // 100, "max": target * 115 // 100},
    }


def invalid_wordcount_result(
    reason: str, *, chapter: Any = None, case_id: Any = None,
    target: int | None = None, actual: int | None = None,
) -> dict[str, Any]:
    return {
        "schema": WORDCOUNT_SCHEMA, "metric": METRIC, "chapter": chapter, "case_id": case_id,
        "target": target, "actual": actual, "internal_band": None, "user_band": None,
        "signed_error_pct": None, "absolute_error_pct": None,
        "status": "invalid", "invalid_reason": reason,
    }


def measure_wordcount(value: str, *, chapter: Any = None, case_id: Any = None) -> dict[str, Any]:
    try:
        actual = count_visible_chars(value)
    except (TypeError, ValueError):
        return {
            "schema": MEASUREMENT_SCHEMA, "metric": METRIC, "chapter": chapter,
            "case_id": case_id, "actual": None, "status": "invalid", "invalid_reason": "INVALID_BODY",
        }
    return {
        "schema": MEASUREMENT_SCHEMA, "metric": METRIC, "chapter": chapter,
        "case_id": case_id, "actual": actual, "status": "measured", "invalid_reason": None,
    }


def evaluate_wordcount(
    value: str, target_value: Any, *, chapter: Any = None, case_id: Any = None,
) -> dict[str, Any]:
    try:
        target = parse_target(target_value)
    except (TypeError, ValueError):
        return invalid_wordcount_result("INVALID_TARGET", chapter=chapter, case_id=case_id)
    try:
        actual = count_visible_chars(value)
    except (TypeError, ValueError):
        return invalid_wordcount_result("INVALID_BODY", chapter=chapter, case_id=case_id, target=target)
    if actual == 0:
        return invalid_wordcount_result("EMPTY_BODY", chapter=chapter, case_id=case_id, target=target, actual=actual)
    bands = compute_wordcount_bands(target)
    internal_pass = bands["internal"]["min"] <= actual <= bands["internal"]["max"]
    user_pass = bands["user"]["min"] <= actual <= bands["user"]["max"]
    status = "internal_pass" if internal_pass else (
        "borderline" if user_pass else ("under" if actual < bands["user"]["min"] else "over")
    )
    signed_error = (actual - target) / target
    return {
        "schema": WORDCOUNT_SCHEMA, "metric": METRIC, "chapter": chapter, "case_id": case_id,
        "target": target, "actual": actual,
        "internal_band": {**bands["internal"], "status": "pass" if internal_pass else "fail"},
        "user_band": {**bands["user"], "status": "pass" if user_pass else "fail"},
        "signed_error_pct": signed_error, "absolute_error_pct": abs(signed_error),
        "status": status, "invalid_reason": None,
    }


def checkpoint_wordcount(
    value: str, target_value: Any, *, chapter: Any = None, case_id: Any = None,
) -> dict[str, Any]:
    target = parse_target(target_value)
    actual = count_visible_chars(value)
    user = compute_wordcount_bands(target)["user"]
    return {
        "schema": CHECKPOINT_SCHEMA, "metric": METRIC, "chapter": chapter, "case_id": case_id,
        "target": target, "actual": actual, "user_band": user,
        "remaining_user_range": {
            "min": max(0, user["min"] - actual),
            "max": max(0, user["max"] - actual),
        },
    }


def target_from_outline(value: str) -> int:
    text = normalize_newlines(value)
    if text.startswith("\ufeff"):
        text = text[1:]
    targets = list(dict.fromkeys(_TARGET_LINE_RE.findall(text)))
    metrics = list(dict.fromkeys(_METRIC_LINE_RE.findall(text)))
    require(len(targets) == 1, "字数目标 must appear exactly once with one value")
    require(metrics == [METRIC], f"字数口径 must appear exactly once as {METRIC}")
    return parse_target(targets[0])


def _chapter_number_from_name(name: str, *, outline: bool) -> int | None:
    pattern = r"^细纲_第0*(\d+)章.*\.md$" if outline else r"^第0*(\d+)章(?:[_\-  ].*)?\.md$"
    match = re.match(pattern, name)
    return int(match.group(1)) if match else None


def find_chapter_file(directory: Path, chapter: int, *, outline: bool) -> Path:
    require(isinstance(chapter, int) and not isinstance(chapter, bool) and chapter >= 1, "chapter must be >= 1")
    require(directory.is_dir(), f"chapter directory is missing: {directory}")
    matches = sorted(
        path for path in directory.iterdir()
        if path.is_file() and _chapter_number_from_name(path.name, outline=outline) == chapter
    )
    label = "outline" if outline else "body"
    require(len(matches) == 1, f"chapter {chapter} must have exactly one {label} file")
    return matches[0]


def build_project_wordcount_record(project: Path, chapter: int, *, resolution: str) -> dict[str, Any]:
    require(resolution in RESOLUTIONS, f"unsupported wordcount resolution: {resolution}")
    root = project.resolve()
    outline_path = find_chapter_file(root / "大纲", chapter, outline=True)
    body_path = find_chapter_file(root / "正文", chapter, outline=False)
    try:
        target = target_from_outline(outline_path.read_text(encoding="utf-8"))
        body_bytes = body_path.read_bytes()
        body = body_bytes.decode("utf-8")
    except (OSError, UnicodeError) as exc:
        raise WordcountError(f"unable to read chapter files: {exc}") from exc
    result = evaluate_wordcount(body, target, chapter=chapter)
    require(result["status"] != "invalid", f"invalid chapter wordcount: {result['invalid_reason']}")
    in_user_band = result["status"] in {"internal_pass", "borderline"}
    require(
        (resolution == "within_user_band" and in_user_band)
        or (resolution == "accepted_current_length" and not in_user_band),
        "wordcount resolution does not match the current length",
    )
    return {
        "metric": METRIC,
        "target": result["target"],
        "actual": result["actual"],
        "status": result["status"],
        "resolution": resolution,
        "body_sha256": hashlib.sha256(body_bytes).hexdigest(),
    }


def normalize_wordcount_record(value: object) -> dict[str, Any]:
    require(isinstance(value, dict), "wordcount record must be an object")
    require(
        set(value) == {"metric", "target", "actual", "status", "resolution", "body_sha256"},
        "wordcount record fields are invalid",
    )
    require(value.get("metric") == METRIC, "wordcount metric is unsupported")
    target = parse_target(value.get("target"))
    actual = value.get("actual")
    require(isinstance(actual, int) and not isinstance(actual, bool) and actual >= 1, "wordcount actual is invalid")
    expected = evaluate_wordcount("字" * actual, target)
    require(value.get("status") == expected["status"], "wordcount status does not match target and actual")
    resolution = value.get("resolution")
    require(resolution in RESOLUTIONS, "wordcount resolution is unsupported")
    in_user_band = expected["status"] in {"internal_pass", "borderline"}
    require((resolution == "within_user_band") == in_user_band, "wordcount resolution does not match status")
    digest = value.get("body_sha256")
    require(isinstance(digest, str) and re.fullmatch(r"[0-9a-f]{64}", digest) is not None, "body_sha256 is invalid")
    return dict(value)


def validate_current_wordcount_record(project: Path, chapter: int, value: object) -> dict[str, Any]:
    record = normalize_wordcount_record(value)
    current = build_project_wordcount_record(project, chapter, resolution=record["resolution"])
    require(record == current, "wordcount record is stale for the current body or target")
    return record
SKILL.md
---
name: story-review
version: 1.1.1
description: "多视角对抗式审查。full/lean 模式在已部署 reviewer agents 时并行 spawn;缺失/异常 agents 或 spawn 失败时自动降级 solo,参考文件不可读时使用内置 rubric fallback。触发方式:/story-review、/审查、「审查一下」「帮我审一下」。"
metadata: {"openclaw":{"source":"https://github.com/zenstory-ai/oh-story-claudecode"}}
---
# story-review:多视角对抗式审查

> Spawn 版本提示(不阻断 spawn):先读取项目根 `.story-deployed` 的 `agents_version`。与本版 `agents_version: 28` 不一致时(标记缺失、字段缺失/非整数、小于或大于 28)**照常按文件存在性检查并 spawn**,但只检查当前运行时的 canonical 目录;同时报告 `Notice: agents bundle 版本不匹配(项目 {N},本版 28)` 并提示重新运行 `/story-setup` 后新开会话;大于 28 时额外提示先更新 oh-story-claudecode,不要用本地旧版 setup 降级覆盖。只有 agent 文件缺失、或运行时不暴露 custom agent 时才降级 solo/direct,报告 `Fallback: ... -> solo`。

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

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

## 作者习惯边界

若作者记忆 state 已存在,审查前用 `scripts/author_memory_commit.py query` 获取本次相关 active 条目(总输出 ≤2KB)。它们只能帮助解释意图和组织报告,不能降低 rubric 严重度、把事实冲突判为无问题或跳过平台门禁;当前请求仍优先。完整规则见 [references/author-memory.md](references/author-memory.md)。

用户对报告格式或协作方式作出稳定声明时,在本轮审查完成后用 `record` 记录并回传回执;重复修正/推断先待确认,一次性要求不记录。审查发现、工具告警和助手建议本身绝不自动学习。

---

## Review Mode 选择

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

---

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

1. **确定请求模式**:解析用户输入中的 `full`、`lean`、`solo`;未指定时目标模式为 `full`。
2. **确认是否允许 spawn**:如果当前已经在子代理/Agent 内执行,不再递归 spawn,直接降级为 `solo`。
3. **识别 ZCode 能力边界**:如果当前运行于 ZCode 且项目使用 `.zcode/`,ZCode 3.3.4 不执行项目/plugin custom agents;不要因为磁盘上存在其他端的 agent 文件就尝试同名 spawn,直接降级 `solo` 并报告 `Fallback: project custom agents unavailable -> solo`。
4. **检查核心 Agent 部署状态**(只检查当前运行时的 canonical 目录,不因其他端文件存在而误判):
   - Claude Code 检查 `.claude/agents/`,OpenCode 检查 `.opencode/agents/`,Codex 检查 `.codex/agents/`,Antigravity 检查 `.agents/agents/`
    - full 必需 agent:`story-architect`、`character-designer`、`narrative-writer`、`consistency-checker`
    - lean 必需 agent:`story-architect`、`consistency-checker`
    - 对每个必需 Agent 文件:
      - **Claude Code agent(`.claude/agents/`)**:读取 frontmatter,确认 `name:` 与 subagent_type 完全一致;frontmatter 缺失、不可解析或 name 不匹配时视为 malformed agent。
      - **OpenCode agent(`.opencode/agents/`)**:文件名即 agent 名(OpenCode 不要求在 frontmatter 中写 `name:`),读取 frontmatter 确认 `mode: subagent` 和 `permission` 字段存在且可解析即可;frontmatter 缺失或不可解析视为 malformed。
      - **Codex agent(`.codex/agents/`)**:文件名为 `{agent}.toml`,TOML 必须可解析,且包含 `name`、`description`、`developer_instructions`;`name` 必须与目标 agent 完全一致。
      - **Antigravity agent(`.agents/agents/`)**:路径为 `.agents/agents/agent-name/agent.md`(`agent-name` 为目标 agent 名),frontmatter 必须可解析,且 `name` 与目标 agent 一致、`mainAgent: false`、`subagent: true`、`tools` 非空;缺失或不匹配视为 malformed。
   - 如果目标模式所需任一文件缺失或 malformed,**不要尝试 spawn 缺失/异常 Agent**;自动降级为 `solo`,并在报告开头写明:`Fallback: missing agents -> solo` 或 `Fallback: malformed agents -> solo`,列出问题文件,建议用户运行 `/story-setup`。
5. **确认 Agent 工具可用**:Claude/OpenCode/Codex 需要当前运行时的子 Agent/Task 调用能力,Antigravity 需要 `invoke_subagent`;不可用时直接降级为 `solo`,报告 `Fallback: agent tool unavailable -> solo`。
6. **运行时失败降级**:如果任何 Agent spawn 返回失败、`subagent_type` / `agent_type` / `TypeName` 不可用、frontmatter/TOML 运行时解析失败或子 Agent 无法启动,停止继续 spawn,改用 `solo` 重新审查,并报告 `Fallback: spawn failed -> solo` 与失败的 agent 名;不要把部分成功的 Agent 结果当成 full/lean 结论。
7. **确定实际模式**:报告中必须同时列出 `Requested Mode` 与 `Effective Mode`。

---

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

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

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

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

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

### 参考资料解析顺序

可读取参考文件时,按以下顺序尝试,第一个命中即用:
1. `{项目根}/.claude/skills/{规范路径}`(Claude Code 项目内安装)
2. `{项目根}/.opencode/skills/{规范路径}`(OpenCode 项目内安装)
3. `{项目根}/.codex/skills/{规范路径}`(Codex 项目内安装)
4. `{项目根}/.zcode/skills/{规范路径}`(ZCode 项目内安装)
5. `{项目根}/skills/{规范路径}`(OpenClaw / Reasonix / generic 部署,也是本仓库开发环境)
6. `{项目根}/.agents/skills/{规范路径}`(Antigravity 项目内真实 skill root;Codex / Reasonix 也可能扫描此目录或其 symlink)
7. 当前运行时加载本 skill 的目录,或其可访问的全局 skill 搜索路径中同名 `{skill-name}/...` 目录

> 靠前几层不存在是正常的,不是部署损坏。`/story-setup` 会为 Antigravity 把 13 个 skill 真实复制到 `.agents/skills/`,为 ZCode 复制到 `.zcode/skills/`,并为 OpenClaw / Reasonix / generic 复制到 `skills/`。Codex 项目部署不复制 skill 本体,本 skill 由 Codex 从 skill root 加载,references 通常命中第 6 或第 7 层。不要手工把 `references/` 复制进 `.codex/skills/`——手工副本不受 story-setup 管理,升级后会静默变旧。

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

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

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

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

通用网文内容 rubric:
- 核心卖点:本章是否围绕明确卖点推进;看不出卖点至少 S2。
- 冲突推进:本章是否有阻碍、选择、代价或关系变化;只解释/闲聊/总结至少 S2。
- 任务卡点:角色办事被卡住时,是否卡出信息、关系、代价、选择或伏笔变化;卡点只剩流程细节、删掉不影响故事至少 S3。
- 情绪曲线:是否有铺垫、升温、释放或反转;情绪平直或突兀至少 S2/S3。
- 钩子与期待:开头或结尾是否制造后续问题;没有悬念或未完成期待至少 S2。
- 开头新鲜度(仅开篇/前 3 章):开局有具体人物/处境切口,还是同题材默认套路(能整体换到任意同类书)?"有钩子/非天气开场"不豁免同质化;套路化开局即使有钩子也至少 S3,整体撞同题材模板 S2。
- 角色动机:行为是否符合目标、性格、处境和关系压力;为剧情服务而失真是 S1/S2。
- 对话质量:是否有潜台词、信息控制、角色差异;说明书式对话至少 S2。
- 设定一致性:不违背已写规则、时间线、角色属性;明确事实冲突通常 S1。
- 文字自然度:具体、可感、动作承载信息;AI 腔、陈词滥调、总结体按影响定 S2/S3。
- 句长节奏:叙述默认是逗号长句(一句用逗号串起 2-4 件事再落句号);碎句和电报体(逗号之间连着都是 ≤5 字、通篇超短句像提纲)与 AI 腔同级,按影响定 S3/S2,不因「短=网文节奏」放行。
- 标点节奏:标点是否服务语气/人物声线;通篇句号化、随机堆砌问号/感叹号,或残留 `……`/`——` 硬造停顿,按影响定 S3/S2。
- 具体字数表达校验:正文用“这五个字 / 短短四字 / 三个字一落 / 八个字砸下去”等具体字数表达评价台词、题字、信件、念头或弹幕时,必须能确认统计口径、机器核对结果和叙事必要;不能确保字数计算正确时,按文字自然度问题处理,建议改成“这句话一落”“那几个字”“话音落下”等非具体数字表达。
- 格式可读性:段落短、对话独立、无多余空行;格式阻碍阅读按 S3,严重混乱按 S2。
- 剧情循环:目标 → 阻碍 → 行动 → 代价/反馈 → 新期待;缺少目标/阻碍/反馈通常至少 S2。
- 高潮构建:蓄能 → 假胜 → 崩解 → 反转/兑现;高潮直接平铺、无代价或无兑现通常 S2/S3。
- 关系进展:互动尺度必须匹配当前关系阶段;越界亲密、突然信任、突然敌对都需要铺垫,否则按影响定 S1/S2。
- 伏笔状态:伏笔状态需可追踪;伏笔密度只作为结构风险提示,除非直接造成理解混乱,否则不升级到 S2+。

AI 味 / 禁用词 fallback 速查:
- 高频套话:`命运的齿轮开始转动`、`心猛地一沉`、`眼神复杂`、`深刻变化`、`踏上新的旅程`。
- 章末总结体:`这一切都说明...`、`他终于明白...`、`新的篇章开始了...`。
- 信息倾倒:角色直接说“我要解释世界观/规则/关系变化”。
- 论文体/万能结论:过度使用“然而、与此同时、不可否认、这意味着”。
- 处理原则:有原文证据才输出 finding;给出可执行替换方向,不只评价“AI 味重”。修法方向不默认「拆短 / 删虚词 / 剥标点」:把正常的逗号长句拆成碎句,与 AI 腔同样是问题。

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

### 传给子 Agent 的规则

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

### 跨批审查落盘契约(所有模式)

只要多章/整卷/整本审查被拆成两批及以上,full、lean、solo 都维护 **{项目根}/.story-review/state.md**:

1. 首批确定本次完整审查范围和批次顺序。每批综合裁决后,用同目录临时文件 + rename 原子重写 state.md,不能只把结果留在对话里。
2. state.md 只记录完整审查范围、已完成范围、下一批,以及“上一批未解决 findings 摘要”。摘要项保留 location、issue 和预计核查/兑现范围。
3. 下一批开始前先读取 state.md,把未解决摘要注入 reviewer prompt;已解决或用户明确不处理的项不再继承,但须在本批输出中说明。
4. 每个项目同时只维护一条跨批审查;若新一轮与 state.md 中未完成范围不同,先说明会丢弃的旧进度并征得用户确认,确认后在首批完成时覆盖。续接时 state.md 缺失、损坏或本批超出既定范围,应明确报告并停止,不猜测旧内容;非分批审查不创建它。

**.story-review/** 只保存审查状态,不属于小说事实追踪;不得借此修改正文、设定、大纲或 `追踪/`。

---

## Phase 1:收集待审查内容

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

**story-explorer 预查询(可选)**。仅当 `Effective Mode` 仍为 `full`/`lean`、当前允许 spawn 且当前运行时的 Agent 工具可用时,才可在对应 canonical agent 目录下确认 `story-explorer` 已部署并 spawn;Antigravity 检查 `.agents/agents/story-explorer/agent.md`,用 `invoke_subagent` + `TypeName: "story-explorer"`。`solo` 或子代理递归保护场景下不得 spawn,只能直接读取/检索。Prompt 示例:

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

---

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

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

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

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

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

---

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

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

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

**Agent 1: story-architect**(subagent_type: story-architect)
- full/lean 均调用。
- 审查视角:主题对齐、大纲结构、钩子/反转质量、范围控制、平台期待。
- 提示指令:
  ```
  你是 story-architect,从故事架构层面审查以下内容。
  你的任务是【找问题】,不是验证正确性。以最严苛的标准审视。
  项目路径:{项目根}
  审查范围:{文件路径/章节/必要摘录}
  审查基准包摘要:{Phase 1 形成的 rubric / fallback 摘要,必须内联}
  Rubric Source: file | embedded fallback
  相关文件路径:{设定/大纲/细纲文件路径}
  继承的开放项(分批审查必填,无则写「无」):{从 追踪/伏笔.md 提取的、预计回收章 ≤ 本批末章的已埋未回收钩子,连同上一批未解决 findings 摘要}
  可选补充参考:本 Skill 的 `story-review/references/review-quality.md`、`story-review/references/plot-core-methods.md`;若不可读,不影响审查。
  检查项:
  1. 这一章是否推进了故事主题?
  2. 大纲结构是否完整(钩子/爽点/悬念)?
  3. 情绪节奏是否合理?
  4. 钩子和反转设计质量如何?
  5. 范围控制:有无角色/设定膨胀?
  6. 剧情循环是否存在且可重复?(参照审查基准包摘要里的剧情循环原则)
  7. 高潮场景是否用了蓄能→假胜→崩解结构?(参照审查基准包摘要里的高潮构建原则)
  8. 伏笔密度、连载期待和结构信息量是否合理?(伏笔密度通常只作为 S4 结构风险,除非已造成理解混乱)
  9. 按平台 rubric 或通用内容 rubric 逐项对照,标记 PASS/FAIL。
  10. 继承的开放项里,本批本该兑现的钩子/伏笔是否落空?
  11. 开头同质化(仅当本章是全书开篇/前 3 章):开局切口是不是同题材的默认套路(穿越即退婚、系统绑定、末世第一天、开场即打脸等),能不能原样换到任意同类书?"有钩子/非天气开场"不等于不同质。对照 references/plot-core-methods.md「噱头分类与开篇流程」判断——能整体换到同类书=同质化(撞题材模板至少 S2;套路化但有具体人物/处境微差 S3)。
  12. 结尾总结:章尾是总结/升华/复述式收尾("就这样……""他终于明白……""这一夜注定……"),还是落在动作/画面/悬念上?检测器已判 blocking 的(`trailer-summary`)按上面「blocking 一律 S2」处理,不重复定级;检测器没覆盖的总结/升华/复述式收尾按影响定 S2/S3(改写走 /story-deslop Gate F,本 skill 只标问题不改写)。

  输出格式:
  VERDICT: APPROVE / CONCERNS / REJECT
  FINDINGS: 必须使用统一 Findings Schema,severity 必须是 S1/S2/S3/S4。
  INHERITED_ITEMS: 逐条列继承的开放项 + 已检查 / 未能检查;本批本该兑现却落空的列为 finding。
  RECOMMENDATIONS: [修改建议]
  ```

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

  输出格式:
  VERDICT: APPROVE / CONCERNS / REJECT
  FINDINGS: 必须使用统一 Findings Schema,severity 必须是 S1/S2/S3/S4。
  RECOMMENDATIONS: [修改建议]
  ```

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

  输出格式:
  VERDICT: APPROVE / CONCERNS / REJECT
  FINDINGS: 必须使用统一 Findings Schema,severity 必须是 S1/S2/S3/S4;AI味级别写入 issue 或 category。
  RECOMMENDATIONS: [修改建议]
  ```

**Agent 4: consistency-checker**(subagent_type: consistency-checker)
- full/lean 均调用。
- 审查视角:grep-first + 推理型一致性检测,输出 S1-S4 报告。
- 提示指令:
  ```
  你是 consistency-checker,使用 grep-first + 推理型一致性审查检测事实矛盾。
  你的任务是【找事实矛盾、状态断线和需要推理才能发现的设定逻辑冲突】,不做创作评判,不评价文学质量,不输出创作修改建议。
  项目路径:{项目根}
  审查范围:{文件路径/章节/必要摘录}
  已知角色:{从设定文件提取角色列表}
  继承的开放项(分批审查必填,无则写「无」):{从 追踪/伏笔.md 提取的、预计回收章 ≤ 本批末章的已埋未回收伏笔,连同上一批未解决 findings 摘要}
  审查基准包摘要:{Phase 1 形成的 rubric / fallback 摘要,必须内联}
  Rubric Source: file | embedded fallback
  可选补充参考:本 Skill 的 `story-review/references/review-quality.md`;若不可读,不影响事实冲突扫描。
  检查项:
  1. 角色属性是否前后一致?
  2. 世界规则是否被违反?
  3. 伏笔状态是否前后一致(已埋/计划回收/已回收/断线)?
  4. 时间线是否自洽?
  5. 术语、身份、地点、能力边界是否前后一致?
  6. 继承的开放项里,本批本该回收的伏笔是否仍悬空?

  输出格式:
  VERDICT: APPROVE / CONCERNS / REJECT
  FINDINGS: 必须使用统一 Findings Schema,severity 必须是 S1/S2/S3/S4;category 只能使用 consistency / factual / format / causal / rule_boundary。
  INHERITED_ITEMS: 逐条列继承的开放项 + 已检查 / 未能检查;本批新发现、不在 伏笔.md 的开放钩子单列,供主会话回写 追踪/伏笔.md。
  FACTUAL_RECONCILIATION: [仅列需统一的事实来源或需人工裁决项,不写文学创作建议]
  REASONING_CHAINS: [仅列推理型 finding 的前提/规则 -> 触发事件 -> 矛盾点 -> 需裁决问题]
  ```

---

## Phase 3:综合裁决

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

---

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

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

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

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

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

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

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

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

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

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

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

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

## 继承到下一批
{仅分批审查填写:逐条列 location、issue、预计核查/兑现范围;无则写“无”}
```

---

## solo 模式

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

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

### solo 模式输出格式

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

## 基础检查结果

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

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

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

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

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

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

### 继承到下一批
{仅分批审查填写:逐条列 location、issue、预计核查/兑现范围;无则写“无”}
```

---

## 追踪文件维护(长篇工程,审查收尾时执行)

新追踪协议只有一个写入口:本 skill 的 `scripts/tracking_commit.py`;完整事务字段和命令见 `references/tracking-transaction.md`。**full / lean 模式只允许通过该工具修改 `追踪/`;solo 模式不修改任何 `追踪/` 文件。**不得直接 Edit/Write/追加 `伏笔.md`、角色快照、时间线视图、摘要或 `上下文.md`。

1. **先检查状态**:执行 `tracking_commit.py check --project {项目根}`,确认 `_tracking-state.json` 与全部派生视图一致。失败时重跑产生当前目标状态的原事务,不得猜测、手改 Markdown 或另造事务覆盖。
2. **判定是否需要修订**:只有正文证据表明现有追踪事实错误或缺失时才维护。过期伏笔、漏登记开放钩子、角色当前状态、客观时间线、读者认知都归入其证据所在章的 `mode=revision` 事务。普通审查意见和未来写作建议不进追踪。
3. **构造完整同章事务**:保留该章原有紧凑增量中仍成立的字段,只修改有证据的变化;核心角色变化同时提交截至当前最后已写章的完整 `character_snapshots`。伏笔对同一 ID `upsert` 当前状态,不增加重复行;时间线同时提交客观事实、读者当前认知和实际揭示状态。
4. **提交并复检**:执行 `tracking_commit.py commit`,再执行 `check`。确认逐章记录规范且未超限、`上下文.md` 恰好固定 7 栏且 ≤12288 字节、作者/读者时间线及全部派生视图与 state 一致。

例如审查 demo 第 10 章时,若正文明确显示周薄森说专业重拍版“缺了灵魂”、张耀祖拍板继续用江晨手机原版,修订事务可以把该结果写进客观事实和读者已知;钟嘉嘉“只猜对了一半”背后的培养安排如果正文尚未揭示,只能留在作者真相,不能写入读者视图。

## 流程衔接

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

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

---

## 语言

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