Retour aux skills
zenstory-ai/oh-story-claudecodeContrôle réussi

SKILL DETAIL

story-import

zenstory-ai/oh-story-claudecode/story-import

逆向导入已有小说。将已写好的小说(半成品或完本)反向解析为标准项目目录结构,兼容 story-long-write / story-short-write 后续写作流程;内部复用 story-long-analyze / story-short-analyze 的拆解管道,按篇幅自动分流。触发方式:/story-import、「导入小说」「反向解析」「导入」「把我的书导进来」。

Installations · 88Voir la source

Installation

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

Fichiers du skill

SKILL.md

Dernière synchronisation · 30 août 2026

references/character-state-reverse.md
# 核心角色当前快照反推规则(长篇导入)

> 仅用于 `story-import` 导入长篇。输出不是一份不断追加的角色历史,而是初始化事务里的 `character_snapshots`;`tracking_commit.py init` 会为每个核心角色生成 `追踪/角色状态/{角色名}.md`。

## 一、输入来源

只从已经落盘的拆书产物反推,不重读 `原文/`:

| 输入 | 用途 |
|---|---|
| `拆文库/{书名}/角色/{角色名}.md` | 身份、能力、目标、成长弧线、出场记录 |
| `拆文库/{书名}/角色/角色关系.md` | 截至最后完整章的关键关系 |
| `拆文库/{书名}/章节/第N章_摘要.md` | 最后位置、最新状态、已知信息与未结事项 |
| `拆文库/{书名}/剧情/*.md` | 阵营/身份转折、阶段目标和长期冲突 |
| 初始化事务中的伏笔与时间线候选 | 核对角色未结事项及其可知范围 |

## 二、追踪对象

只为主角、反派、核心配角建立独立快照。临时路人和只承担一次功能的角色不建文件。边界不清时优先不建,后续角色真正进入复用状态时再由逐章事务创建。

## 三、反推方法

对每个核心角色,以最后完整导入章 N 为截面,分别确定:

1. `identity`:截至 N 章实际成立的身份/职业,不写未来晋升计划。
2. `location`:最后落点或下一章开始前可确定的位置。
3. `goal`:角色当前正在追求的具体目标。
4. `state`:身体、情绪、名望、立场中会影响续写的当前状态;只写必要信息。
5. `abilities_resources`:当前确实掌握的能力、物品、权限、作品或人脉,最多 8 条。
6. `relationships`:与后续会复用角色的当前关系,最多 8 条。
7. `knowledge`:角色本人已经知道、且会影响其行为的信息,最多 8 条。作者真相不能误写成角色已知。
8. `open_threads`:角色相关的未结事项,最多 8 条;必须有已写正文证据,纯未来设计留在大纲。

若同一字段有多次变化,只取截至 N 章的当前值,不把变化史塞进快照。历史变化由后续 `逐章记录/第NNN章.md` 承担;导入旧章不补造这些记录。

## 四、初始化 JSON 形状

以下示例来自 demo《让你管账号,你高燃混剪炸全网》第 10 章:

```json
{
  "江晨": {
    "identity": "火箭军文工团宣传兵;军宣爆款创作者",
    "location": "火箭军文工团高层看片会",
    "goal": "完成五天百万粉任务,继续做出真正能打的军宣内容",
    "state": "专业团队重拍版反向坐实手机原版的价值,军内认可继续抬升",
    "abilities_resources": [
      "前世 MCN 爆款运营经验",
      "《中国军魂》伴奏",
      "大师级导演能力"
    ],
    "relationships": [
      "钟嘉嘉持续提供军报资源",
      "周薄森和张耀祖已明确认可其创作能力"
    ],
    "knowledge": [
      "《军报》采访稿已经过审",
      "高层决定继续采用《诸君,且听龙吟》手机原版"
    ],
    "open_threads": [
      "五天百万粉任务尚未结算",
      "钟嘉嘉所谓只猜对一半仍未解释"
    ]
  }
}
```

工具据此生成的文件固定包含:截至章节、身份、位置、当前目标、身心状态、能力与资源、关键关系、已知信息、未结事项。模型不得另写一套 Markdown 模板。

## 五、残稿与分批导入

- 最后一章是残稿:所有快照截至残稿之前的最后完整章;残稿中的动作、获得物、关系变化不得提前生效。
- 分批导入:快照只表示本次已导入范围的当前状态;扩大导入范围时重新执行一次完整导入,不在旧快照上追加历史段落。

## 六、质量检查

- [ ] 每个文件对应一个核心角色,无功能角色堆积
- [ ] 所有字段都是截至最后完整章的当前值
- [ ] 角色已知信息没有混入作者真相
- [ ] 未结事项都有正文证据,未来设计仍在大纲
- [ ] 单角色快照目标不超过 4096 字节;确有必要可放宽,但不得超过 8192 字节硬上限
- [ ] `tracking_commit.py check` 通过
references/format-and-structure.md
# 正文格式与小节结构

> 写作前必读。以下格式是当前仓库约定的默认正文交付格式;用户或目标平台有明确要求时,以用户/平台要求覆盖。
>
> **适用范围**:段落格式(戏剧单元/镜头优先,短段为底色,长段用于完整推理、氛围和情绪链)和对话格式适用于所有体裁。小节(beat)结构和字数标准仅适用于短篇。长篇按 `visible_chars_v1` 测量同口径细纲 `字数目标`:内部带 ±12%,用户带 ±15%。

---

## 章节标记

默认格式(按灵活度排序):

| 格式 | 平台适用 | 示例 |
|------|----------|------|
| `###1.` | 短篇默认 | `###1.` `###2.` `###3.` |
| `###第一章` | 部分平台 | `###第一章` `###第二章` |
| `1.`(纯数字) | 知乎 | `1.` `2.` `3.`(无 ### 前缀) |

**规则**:全文统一一种格式,不要混用。短篇推荐 `###1.` 或纯数字,简洁高效。

---

## 段落格式

### 核心规则:戏剧单元优先

默认交付排版是**按戏剧单元/镜头自然断段,段落紧密排列**。不要把固定字数当成强制切刀;先判断“一件事/一个推理链/一个情绪变化”是否完成。

- 一段承载一个戏剧单元:一个动作链、一个线索发现、一次视线切换、一轮心理判断,或一条连续的氛围/推理/情绪链。
- 场景结束、一件事结束、新动作、新物件、新信息、新对话另起一段;同一瞬间的发生、感知、反应应织在一起,不拆成动作层/感知层/反应层。
- 正文相邻段落之间**只允许一个换行符 `\n`**;不得出现空行或连续换行 `\n\n`(紧密排列)。
- 无缩进(平台渲染器自行处理,不需要 `  ` 或空格)。
- 长度只作诊断:读起来拥挤、混入多个拍点、或手机屏上难以跟读时才拆;完整推理、氛围铺陈、情绪递进未结束时,允许稍长段保留连贯性。

### 段落节奏(长短交错 + 疏密有别)

短段快读,是网文手机阅读的底色;长段负责承载完整推理、氛围和情绪沉淀。**忌通篇同长度**,也忌把每段按同一字数阈值切开:

- **长短交错**:高潮、打脸、反转压到最短(单句成段);推理链、环境压迫、情绪沉淀、章节收束可保留较长段,让读者读完一个完整变化。
- **疏密有别(详略)**:爽点、转折 beat 写密(感知、动作、细节铺满);过场、连接 beat 写疏(1-2 句带过,不平均用力)。每个 beat 一样长、一样细,正是 AI 腔的来源。
- **不过度碎片化**:连续多个极短段若仍属于同一镜头/同一件事,应合并成自然段,避免像提纲或诗行。

### 主语与角色名节奏

角色名不宜一味省略,也别每句都点名。按“主语重置”使用:

- 段首、场景切换、多人同场、或主语可能混淆时,用主角名/角色名建立视角。
- 同一段或同一动作链内,优先混用代词、动作承接和合理省略,避免每句都以同一角色名开头。
- 关键转折、情绪爆点、身份反差或读者需要重新盯住主角时,可以再次点名强化。
- 审查主语节奏看“读起来是否打磕巴”,不按全章出现次数一刀切;只有连续句/连续段无主语重置却反复点名,才算主语过密。

---

## 对话格式

### 对话标记

按目标平台/用户要求选择;未指定时使用默认格式:

| 优先级 | 格式 | 适用平台 |
|--------|------|----------|
| 首选 | `"说话内容"` | 短篇默认、番茄 |
| 平台/项目指定 | `「说话内容」` | 知乎盐言短篇、部分古言、日式或用户指定 |

**默认用 `""`**;用户或平台指定盐言风格时改为 `「」`,不要把 `「」` 视为错误。

### 对话规则

1. 对话**独立成行**,不嵌在叙述段落中
2. 对话标签按需:高频或公式化的「他说」「她道」「他笑了笑说」用动作描写替代;普通「说」低频使用可保留(与「8 条绝对禁止」中「避免对话标签机械化」一条一致)
3. 两人对话连续出现时,省略标签,靠内容区分说话人

**正确示例**:
```
她把杯子放下。
"你走吧。"
他没有动。
"我说,你走吧。"
```

**也合法(普通「说」低频使用)**:
```
她把杯子放下。
"你走吧。"她说。
他没有动。
"我说,你走吧。"
```

**错误示例**:
```
她把杯子放下,说道:"你走吧。"他没有动,她又说:"我说,你走吧。"
```

---

## 语气标点谱系

标点服务语气、人物声线和情绪节奏,不能通篇句号化,也不能为了“丰富”随机堆砌符号。先判断当前句子的功能,再选择标点:

| 语气 / 功能 | 标点策略 | 防线 |
|---|---|---|
| 压迫 / 冷静 / 克制 | 短句、逗号、句号,必要时用冒号压出判断落点 | 不人工加感叹号;克制不是每句都平铺句号 |
| 质问 / 试探 / 反问 | 问号 + 短促追问片段,配合动作停顿 | 避免每句话都以 `?` 结尾 |
| 惊讶 / 爆发 / 打脸 | 真正爆点可用 1 个感叹号,连续爆发最多 1-2 处 | 禁止 `!!!`、整段喊叫式感叹 |
| 犹豫 / 吞咽 / 未说完 | 逗号、句号、短句断开、动作 beat | 不用 `……` 制造停顿;优先用动作和句长变化 |
| 被打断 / 拖长音 | 不使用 `——`;改用动作打断、换行、短句或未完成动作 | 正文和对话都禁止 `——` / `—` / `--` |
| 信息揭示 / 判断落点 | 冒号、分号或单句成段制造落点 | 保持手机阅读友好,不写论文式分号串 |

执行规则:
- 写对话时先看角色关系和权力位置:强势角色常短句收束,试探角色多问号和半句,崩溃角色才允许少量感叹/省略。
- 写叙述时用句长、逗号停顿和单句成段制造节奏;不要把破折号当节奏工具,对话里也不要用。
- 精修时检查两类问题:**通篇句号化**(语气全被压平)与**随机标点堆砌**(问号/感叹号不承载情绪功能,或用省略号/破折号硬造停顿)。
- 引号风格按项目/平台约定;知乎盐言的 `「」` 是合法对话格式,`quote-mode keep` 时不得擅自改掉。

---

## 小节(beat)结构

### 基本规则

- 用数字编号(`1` `2` `3`)分割小节,每个小节是一个完整的叙事 beat
- 每小节 800-1500 字(爽文等高信息密度题材可压缩至 500-800 字/节);长篇章节不套用本节口径,按细纲「字数目标」处理
- 8-15 个小节覆盖全文(按目标字数反推;8000÷1000≈8节,12000÷800≈15节)
- 每小节推进一个明确的情节点

### 小节内部结构

每个小节应该有:

1. **一个主事件** + **3-5 个子事件**(主事件推进核心情节,子事件丰富层次;初稿偏短时先回到小节/细纲补足计划内子事件、对话或冲突,再写正文;去AI味已有正文时不得新增剧情)
2. **一个情绪变化**(读者的感受如何变化)
3. **一条读者新获知的信息**
4. **3-5 轮对话交锋**(揭示关系/升级冲突,参考 writing-craft.md 对话权力博弈。特定场景如独自发现/翻阅日记可标零,按当前 skill 的细纲/设计任务要求执行)
5. **每个子事件三维度揉进**:发生+感知+反应揉进同一段正文(参考 writing-craft.md 场景写法)


### 小节之间的衔接

- 小节结尾留一个钩子(悬念/未解决的情绪/新问题)
- 下一节开头快速接续,不要重新铺垫
- 情绪跨节递进:每一节的情绪强度 ≥ 上一节。例外:峰值情绪(反转节)后允许维持 1 节不降,但不允许骤降

---

## 平台对话格式覆盖表

| 平台 | 章节标记 | 对话格式 | 特殊要求 |
|------|----------|----------|----------|
| 知乎盐言 | `1.` | `「」` | 导语需单独标注 |
| 番茄 | `###第一章` | `""` | 首段需有吸引力 |
| 红果 | `###1.` | `""` | 无 |

**通用原则**:用户未指定平台时,默认使用短篇通用格式(`###1.` + `""`);用户或平台指定盐言风格时,`「」` 是允许的。

---

## 8 条绝对禁止

以下规则在写作全程执行,不因题材或风格而变:

1. **禁止机械按字数分段**:不要因为超过某个字数就强拆;先判断段落是否仍是一个完整戏剧单元。若混入多个动作/信息/视线切换才拆,完整推理、氛围、情绪链可保留稍长段
2. **禁止段间空行**:正文相邻段落之间只允许一个换行符 `\n`,不得出现空行或连续换行 `\n\n`
3. **避免对话标签机械化**:高频或公式化的「他说」「她道」「他笑了笑说」用动作/上下文替代;普通“说”可保留
4. **禁止缩进**:不使用 `  `(全角空格)或半角空格缩进
5. **禁止正文段落 Markdown 渲染**:除统一的小节/章节标记(如 `###1.`)外,正文段落中不使用加粗 `**`、斜体 `*`、标题 `#`、分隔线 `---` 等 Markdown 语法
6. **正文不使用破折号**:正文(含叙述、对话、心理描写)不使用破折号 `——`/`—` 或双连字符 `--`,改用句号、逗号、换行、动作 beat 或短句断开;不再设置“对话例外”
7. **禁止通篇句号化或随机标点堆砌**:标点必须跟语气/人物声线/情绪功能匹配;该质问时用问号,该爆发时少量感叹;犹豫、吞咽、未说完用动作或句长变化表达,不用 `……` / `——` 硬造停顿,也不得无功能地乱撒 `?`/`!`
8. **禁止正文混入章节元信息**:章节号只允许出现在标题/小节标记/文件名/追踪记录中。正文叙述、对话、心理描写里不得出现 `第[一二三四五六七八九十百千万两0-9]+章|上一章|上章|前一章|本章|这一章|前文|后文|伏笔|细纲|读者` 这类写作工程词;要改成角色能感知的事件锚点或相对时间,例如把“比第一章那三秒开火更疼”改成“比那三秒开火更疼”。例外:角色在故事世界内真实阅读/讨论“第X章”文本,或真实身为作者/读者并谈论读者身份时,可保留相应词。
references/length-routing.md
# 篇幅分流判断规则

Phase 1「基本信息确认」环节使用本规则判定导入书是长篇还是短篇,判定结果决定后续走哪条迁移路径。

---

## 判定优先级

判定按以下顺序执行,命中即停止,不再向下判断。

| 优先级 | 信号来源 | 判定规则 |
|--------|---------|---------|
| 1 | 用户显式声明 | 用户说「这是长篇 / 短篇」→ 以用户为准,直接锁定 |
| 2 | 结构信号 + 字数校验 | 检测到明确章节分隔符且章节数 ≥ 5 → 长篇;全文无章节分隔、单文件单篇 → 按下方细则表按字数分三档判定(字数阈值沿用优先级 3 的建议值) |
| 3 | 字数兜底 | 仅在 1、2 均不明确时启用,见下方「字数兜底规则」 |
| — | 冲突处理 | 信号之间发生矛盾时,不自动决策,回到 Phase 1 向用户复述并请用户拍板 |

---

## 优先级 1:用户显式声明

Phase 1 信息确认时向用户提问:**「这是长篇还是短篇?」**

- 用户明确回答 → 锁定类型,跳过后续检测。
- 用户未回答或说「不确定」→ 进入优先级 2 结构信号检测。

---

## 优先级 2:结构信号 + 字数校验

### 章节分隔符识别

复用 `structure-mapping-long.md` 的分隔符识别表:

| 分隔符模式 | 示例 |
|-----------|------|
| `第X章` / `第X章 ` / `第X章:` / `第X章 XXX` | 第1章 初入江湖 |
| `Chapter X` | Chapter 1 |
| 纯数字编号 + 标题 | 1. 觉醒 |

### 判定规则

| 检测结果 | 判定 |
|---------|------|
| 有明确章节分隔符,且识别到章节数 ≥ 5 | 强长篇信号 → 判定为**长篇** |
| 全文无任何章节分隔符,单文件单篇,且总字数 < 20000 | 强短篇信号 → 判定为**短篇** |
| 全文无任何章节分隔符,单文件单篇,20000 ≤ 总字数 < 30000 | 判定为**短篇**,但 Phase 1 复述时告知:已超过短篇拆解管道 20000 字的建议上界,请用户确认仍按短篇导入 |
| 全文无任何章节分隔符,单文件单篇,但总字数 ≥ 30000 | 结构与字数信号相反 → 不自动判定,见下方「冲突处理」请用户拍板 |
| 有章节分隔符,但章节数 < 5 | 结构信号不明确 → 进入优先级 3 字数兜底 |
| 分隔符模式模糊(如仅有单个标题行) | 结构信号不明确 → 进入优先级 3 字数兜底 |

---

## 优先级 3:字数兜底

**仅在优先级 1、2 均无法给出明确判定时使用。** 当前导入契约按短篇通常 8000-20000 字的区间取 30000 字作建议上界,并保留题材差异余量。

| 条件 | 判定 | 备注 |
|------|------|------|
| 总字数 < 30000 且无章节结构 | **短篇** | — |
| 总字数 ≥ 30000 | **长篇** | — |
| 章节数 ≥ 5(任意字数) | **长篇** | — |
| 总字数 < 30000 但章节数 ≥ 5 | 初判**长篇**,但须提示用户 | 见下方「分章短篇连载」 |

> **建议值说明**:30000 字阈值是估算值,不同平台和题材的短篇上限有差异。执行时列入 open-questions,建议用户复核是否适用于当前导入书。

### 分章短篇连载

检测到总字数 < 30000 但章节数 ≥ 5 时,在确认环节提示用户:

> 「检测到分章结构(共 {N} 章),但总字数约 {X} 字,低于 30000 字。可能是分章短篇连载。确认按长篇导入,还是按短篇处理?」

用户拍板后锁定类型。

---

## 冲突处理

当不同信号之间出现矛盾时,**不自动决策**,回到 Phase 1 向用户复述检测结果:

| 典型冲突场景 | 处理方式 |
|------------|---------|
| 用户说「短篇」但检测到 20 章 | 复述:「检测到 20 章章节结构,通常属于长篇。确认按短篇导入?」由用户拍板 |
| 用户说「长篇」但全文无章节分隔且字数 < 30000 | 复述:「全文无章节分隔,总字数约 {X} 字,通常属于短篇。确认按长篇导入?」由用户拍板 |
| 用户未声明、全文无章节分隔、单文件单篇,但字数 ≥ 30000 | 复述:「全文无章节分隔,但总字数约 {X} 字,已超过短篇常见上界。按长篇导入建工程,还是仍按短篇处理?」由用户拍板 |
| 结构信号与字数信号方向相反 | 展示两项信号,请用户决定 |

用户拍板的结果记入 Phase 1 上下文,后续步骤以此为准,不再重新判定。

---

## 判定结果与后续路径

判定完成后,迁移路径按以下对应关系分流:

| 判定结果 | 迁移路径 | 映射规则参考文件 |
|---------|---------|----------------|
| **长篇** | 长篇迁移路径(Phase 3-L) | `structure-mapping-long.md` |
| **短篇** | 短篇迁移路径(Phase 3-S) | `structure-mapping-short.md` |

> 注:`structure-mapping-long.md` 对应长篇迁移映射规则,`structure-mapping-short.md` 对应短篇迁移映射规则。

---

## 快速判定流程图

```
Phase 1 问用户:「长篇还是短篇?」
         │
         ├─ 用户明确回答 ──────────────────────────► 锁定类型
         │
         └─ 未回答 / 不确定
                  │
                  ▼
         检测章节分隔符
                  │
                  ├─ 有分隔符且章节数 ≥ 5 ──────────► 长篇
                  │
                  ├─ 无分隔符,单文件单篇,< 20000 ─► 短篇
                  │
                  ├─ 无分隔符,单篇,20000 ≤ 字数 < 30000 ─► 短篇(复述时告知超出建议上界)
                  │
                  ├─ 无分隔符,单文件单篇,≥ 30000 ─► 提示用户裁定
                  │
                  └─ 信号不明确
                            │
                            ▼
                   字数兜底判定(30000 字阈值)
                            │
                            ├─ < 30000 且无章节结构 ─► 短篇
                            ├─ ≥ 30000 ─────────────► 长篇
                            ├─ 章节数 ≥ 5 ──────────► 长篇
                            └─ < 30000 但章节数 ≥ 5 ─► 提示用户裁定
```
references/state-tracking.md
# 状态追踪协议

> 本文件定义"本节速记"的提取逻辑和"角色状态记录"的格式。写作 skill 在写前准备的状态筛选步骤中加载此文件。

---

## 本节速记

写作每一章/每一节前,从所有已加载的上下文中筛选出只与本节相关的信息。目的是避免全量加载导致 LLM 上下文被无关信息稀释。

### 筛选逻辑

从上下文中提取三类信息:

1. **当前状态**:本章涉及角色的最新能力、关系变化、公众形象
2. **历史因果**:与本章事件直接相关的伏笔成因、前史事件
3. **世界约束**:本章涉及的世界观规则(力量体系、社会规则、地理限制)

### 筛选标准

**只保留"如果不知道这个,本章会写错"的信息。**

具体判断方法:
- 本章细纲中提到了某个角色 → 保留该角色的当前状态
- 本章有伏笔回收 → 保留伏笔的埋设细节和前文铺垫
- 本章涉及特定地点/能力/规则 → 保留相关世界观约束
- 纯背景知识(与本章事件无因果关联) → 丢弃

### 输出格式

筛选后输出一个简洁的"本节速记":

```
## 本节速记(第{N}章)

### 角色状态
{角色A}:{一句话当前状态,含最近变化}
{角色B}:{一句话当前状态}

### 相关伏笔/前史
{伏笔1}:{埋设细节,{埋设章节}},本章需要{回收/推进}

### 世界约束
{约束1}:{与本章相关的规则/设定}
```

**示例(demo《让你管账号,你高燃混剪炸全网》第 10 章):**
```
## 本节速记(第10章)

### 角色状态
江晨:火箭军文工团宣传兵,《诸君,且听龙吟》已爆红,正从军宣新人升为军内认可的创作者
钟嘉嘉:军报记者,采访稿已经过审,与江晨从采访关系转为稳定合作
周薄森:文工团副团长,已从担心江晨惹事转为认可其创作能力

### 相关伏笔/前史
五天百万粉任务:第1章发布,第10章仍未结算;本章只推进声量,留到第11章结算
专业团队重拍:本章用高清专业版“缺了灵魂”反衬江晨手机原版不可替代

### 世界约束
军宣作品采用要经过组织决策;江晨可以靠作品效果赢得认可,不能跳过军内流程直接拍板
```

---

## 角色状态记录格式

> ⚠️ **本节仅适用于长篇写作。** 短篇通常不需要独立角色状态追踪。

动态状态按核心角色拆成 `追踪/角色状态/{角色名}.md`,由 `tracking_commit.py` 根据事务 JSON 整份覆盖;静态原始人设仍在 `设定/角色/{角色名}.md`。只为后续会复用的主角、反派、核心配角建快照,不为路人、一次性功能角色建文件。

### 格式

```markdown
# 江晨|当前状态

- 截至章节:第10章
- 身份:火箭军文工团宣传兵;军宣爆款创作者
- 位置:火箭军文工团高层看片会
- 当前目标:完成五天百万粉任务,持续做出真正能打的军宣内容
- 身心状态:专业团队反向验证原版价值,军内认可继续抬升

## 能力与资源
- 前世 MCN 爆款运营经验
- 《中国军魂》伴奏
- 大师级导演能力

## 关键关系
- 钟嘉嘉持续提供军报资源
- 周薄森和张耀祖已明确认可其创作能力

## 已知信息
- 《军报》采访稿已经过审
- 原版视频将继续作为正式军宣内容

## 未结事项
- 五天百万粉任务尚未结算
- 钟嘉嘉所谓“只猜对一半”仍未解释
```

### 更新规则

1. 每章只记录身份、位置、目标、身心状态、能力资源、关系、已知信息、未结事项中真正发生变化的内容。
2. 核心复用角色发生变化时,在同一事务的 `character_changes` 写变化,并在 `character_snapshots` 提交其**截至当前已写最后一章**的完整快照;工具以快照是否存在判断核心/临时角色,整份覆盖核心角色小文件,不追加历史。
3. 修订旧章时,从修订章检查到最后已写章,按各维度和各关系对象重算当前状态,再提交完整快照;不得把最后一条单维度变化当成角色全状态。
4. 无变化可不提交快照;核心角色重新进入当前场景时直接读取其已有小文件,并由事务工具把必要信息带入续写状态卡。
5. 角色变化历史属于 `逐章记录/第NNN章.md`,当前快照不重复保存逐章履历。
6. 完整 JSON 字段、4096 字节目标与 8192 字节硬上限见 [tracking-transaction.md](tracking-transaction.md)。
references/structure-mapping-long.md
# 结构迁移映射规则(长篇)

Phase 3-L 长篇结构迁移的详细映射规则和模板。将 `拆文库/{导入书名}/` 分析结果转换为 `{导入书名}/` 长篇项目结构。

> 短篇迁移规则见 `structure-mapping-short.md`。

> **名称边界**:`{导入书名}` 是用户自己的待续写小说;`{对标书名}` 是另行选择的外部参考作品。两者的数据源必须分开,禁止把 `{导入书名}` 的拆文结果或项目 `设定/` 写入 `对标/`。

---

## 映射总览

| 拆文库路径 | 项目路径 | 转换方式 |
|-----------|---------|---------|
| `原文/` | `正文/第XXX章_章名.md` | 按章节分割并标准化命名 |
| `快速预览.md` | — | 参考卷划分候选、剧情走向,不直接迁移 |
| `角色/{角色名}.md` | `设定/角色/{角色名}.md` | 增加角色模板字段 |
| `角色/角色关系.md` | `设定/关系.md` | 格式转换 |
| `设定/世界观/*.md` | `设定/世界观/*.md` | 按主题原样同步 |
| `设定/势力/*.md` | `设定/势力/*.md` | 按势力原样同步 |
| `剧情/故事线.md` | `大纲/大纲.md` | 反推卷级结构(需用户确认卷划分,见下) |
| `剧情/{标题}.md` | `大纲/卷纲_第X卷.md` | 聚合为卷纲 |
| `剧情/节奏.md` | `设定/题材定位.md`(节奏摘要) | 提炼已写部分的既有节奏,保留为本书导入基线;不复制到 `对标/` |
| `剧情/情绪模块.md` | `设定/题材定位.md`(情绪摘要) | 提炼已写部分的读者需求与情绪引擎;不复制到 `对标/` |
| `章节/第N章_摘要.md` | `大纲/细纲_第N章.md` | 反推细纲 |
| — | `设定/题材定位.md` | 从拆文报告生成 |
| — | `追踪/_tracking-state.json.imported_through_chapter` | 记录导入截止章 N;第 1..N 章不伪造日更记录 |
| — | `追踪/伏笔.md` | 每个已实际埋设/回收的伏笔 ID 只保留一行当前状态 |
| — | `追踪/_tracking-state.json.timeline` | 同一事件登记客观事实、读者认知与实际揭示状态;Markdown 时间线只做派生阅读视图 |
| — | `追踪/时间线/作者真相.md`、`读者已知.md` | 由 `_tracking-state.json.timeline` 派生;读者视图不得泄露作者秘密 |
| — | `追踪/角色状态/{角色名}.md` | 由 `character-state-reverse.md` 反推核心角色当前快照 |
| — | `追踪/逐章记录/` | 创建空目录;导入章不伪造日更记录,续写从 N+1 章生成 |
| `剧情/散落情节.md` | `大纲/大纲.md` 附录或对应卷纲 | 合并到相关卷的大纲 |
| — | `追踪/上下文.md` | 由初始化事务生成续写状态卡(固定 7 栏),≤12288 字节 |

---

## 正文标准化规则

### 命名格式

源文件名 → 标准格式:`第{零填充三位}章_{章名}.md`

| 源文件名 | 标准化后 |
|---------|---------|
| 第一章_初入江湖.txt | 第001章_初入江湖.md |
| 第1章.md | 第001章_无题.md |
| chapter01.md | 第001章_无题.md |
| 01_觉醒.md | 第001章_觉醒.md |

### 章节分隔识别

当源为单个大文件时,按以下分隔符切分(与 `length-routing.md` 优先级 2 复用同一识别表):

| 分隔符模式 | 示例 |
|-----------|------|
| `第X章` / `第X章 ` / `第X章:` / `第X章 XXX` | 第1章 初入江湖 |
| `Chapter X` | Chapter 1 |
| 纯数字编号 + 标题 | 1. 觉醒 |

### 内容处理

- 保留原文内容不变,不做任何修改
- 编码统一为 UTF-8
- 去除文件头尾无关信息(如广告、声明等)

---

## 角色文件迁移模板

```markdown
---
name: {角色名}
---

# {角色名}

## 基本信息
- 身份:{从拆文库角色文件提取}
- 核心特质:{}
- 当前能力:{}
- 核心动机:{}
- 弱点/缺陷:{}

## 外在表现
{身份/言行/外貌}

## 内在分析
{性格/目标/秘密}

## 出场记录
| 章节 | 关键事件 | 状态变化 |
|------|---------|---------|
| 第{N}章 | {事件} | {变化} |

## 别名
{如有别名,列出}
```

---

## 关系文件转换规则

拆文库格式(角色关系.md)→ 项目格式(设定/关系.md):

```
拆文库格式:
A<->B:关系类型 | 情感 | 描述(50-200字)| 演变轨迹

项目格式:
| 角色 A | 角色 B | 关系类型 | 情感倾向 | 当前状态 | 起始章节 | 变化节点 |
```

转换规则:
- 关系类型映射:家人→亲情、恋人→爱情、朋友→友情等
- 情感倾向直接复用:正面/负面/中性/复杂
- 演变轨迹提取到「变化节点」列

### 目标格式模板(设定/关系.md)

```markdown
# 角色关系图

## 关系总览

| 角色 A | 角色 B | 关系类型{亲情/爱情/友情/敌对/师生/主从/利益} | 情感倾向{正面/负面/中性/复杂} | 当前状态 | 起始章节 | 变化节点 |
|--------|--------|---------------------------------------------|-----------------------------|---------|---------|---------|
| {名} | {名} | {类型} | {倾向} | {描述} | 第{N}章 | {事件} |

## 关系演变

{角色A}<->{角色B}:
- 起点:{初始关系}
- 转折:{章节·事件·变化}
- 当前:{现状}

## 核心冲突关系

{列出推动剧情的2-3对核心对立/合作关系}
```

---

## 世界观同步规则

当前 `story-long-analyze` 已输出主题化目录,导入阶段只做 pass-through,不再解析或拆分扁平 `世界观.md`。

| 源路径 | 目标路径 | 当前契约 |
|---------|---------|---------|
| `拆文库/{导入书名}/设定/世界观/*.md` | `{项目}/设定/世界观/*.md` | 原样同步;`背景设定.md` 必须存在 |
| `拆文库/{导入书名}/设定/势力/*.md` | `{项目}/设定/势力/*.md` | 原样同步已独立的势力文件 |

`力量体系.md`、`地理.md` 或小势力资料不足 200 字时,上游会将其并入 `背景设定.md`,因此这些独立文件可省略。如缺少 `背景设定.md`,或当前内容指向独立力量体系却未产出对应文件,停止导入并提示重跑 `story-long-analyze` Stage 4。

---

## 大纲反推规则

### 大纲.md(卷级结构)与卷划分规则

从 `剧情/故事线.md`、`剧情/*.md` 和 `快速预览.md` 反推,**卷划分必须遵守以下决策规则**:

**情形 A:原文有明确卷界**

原文中存在明确卷级标记(如「第一卷 XXXX」「卷一」等章节层级标题)→ 按原文卷界直接划分,无需询问用户。

**情形 B:原文无明确卷界**

不做机械切卷。执行流程:

1. 根据故事线/场景切换/大型时间跳跃,检测候选卷边界(见下方「候选边界检测参考」);
2. 向用户展示候选划分方案,格式示例:

   ```
   候选卷划分(供参考,非定论):
   - 候选卷一:第 1-18 章(世界观建立 + 初步成长,场景:城郊学院)
   - 候选卷二:第 19-45 章(主线冲突爆发,场景:帝都议事堂)
   - 候选卷三:第 46-XX 章(最终对决,场景切换:上古遗迹)
   以上为故事线/场景切换自动检测结果,请确认或调整。
   ```

3. **等待用户确认卷划分方案**后,才生成 `大纲/大纲.md` 的卷级结构和对应 `大纲/卷纲_第X卷.md`;
4. 用户未确认前,`大纲/大纲.md` 只记录候选方案,不写定卷纲。

> **不允许**用「每卷默认 20-40 章」机械切分原文无卷界的书。候选仅作参考,最终由用户拍板。

### 候选边界检测参考

| 信号类型 | 示例 | 卷边界可能性 |
|---------|------|------------|
| 章节连续 + 同一故事线 | 同一城市/同一势力视角 | 同一卷 |
| 主要场景切换(新地图/新阵营) | 从城郊进入帝都 | 候选新卷起点 |
| 大型时间跳跃(数月/数年) | 「三年后…」 | 候选新卷起点 |
| 主要阶段目标完成 + 新目标开启 | 击败阶段 boss → 新危机出现 | 候选新卷起点 |
| 故事线汇总中已有阶段划分 | `剧情/故事线.md` 内部分段 | 优先参考 |

### 卷纲反推

#### 目标格式模板(大纲/卷纲_第X卷.md)

卷纲是大纲的展开——大纲决定方向,卷纲决定节奏。包含本卷全部创作规划。

```markdown
# {卷名} 卷纲

## 核心信息
- 章节范围:第{X}-{Y}章
- 字数目标:{W}万字
- 本卷定位:{铺垫/发展/高潮/转折/收尾}

## 核心矛盾
{一句话:本卷要解决什么问题或达到什么目标}

## 情绪弧线
- 模板:{V形/倒V形/W形/渐进形/延迟满足形/急转弯形}
- 选择理由:{结合题材和本卷定位}

| 章节 | 情绪基调{紧张/轻松/悲伤/热血/温馨/震惊} | 强度{1-10} | 触发事件 |
|------|-----------------------------------------|-----------|---------|
| 第{N}章 | {基调} | {N} | {事件} |

## 卷契约与终局储备(反推)
- 卷契约:{从本卷剧情归纳读者期待与主角高光;证据不足写 `[待补充]`}
- 本卷主推线:{从情节点归纳承担本卷最大高潮的线}
- 本卷战果:{其余顺带兑现的线;证据不足写 `[待补充]`}
- 本卷解锁的终局里程碑:`[待补充]`
- 本卷禁碰的终局底牌:`[待补充]`
- 契约风险:{契约安全 / 需补强 / 契约破坏;无法判断写 `[待补充]`}

## 剧情单元(反推)
| 单元ID | 章节范围 | 单元节拍(铺垫→释放→反应层→衔接) | 主推线/战果 | 下一单元因果钩子 |
|------|---------|---------|-------|---------|
| L{卷}-1 | {章X-Y} | {从爽点/情节点分布归纳} | {线} | {方式} |

(导入反推只填有证据的字段,未知写 `[待补充]`、不杜撰;后续补纲/改纲时按 story-long-write 技能的「剧情单元卡」完整字段模板升级。)

## 人物弧线
| 角色 | 本卷起点 | 本卷终点 | 关键转变 |
|------|---------|---------|---------|
| {名} | {状态} | {状态} | {事件} |

## 本卷反转(如有)
| 类型{身份/动机/阵营/信息/命运} | 涉及角色 | 误导路径 | 揭示章节 | 影响范围 |
|------|---------|---------|---------|---------|
| {类型} | {名} | {如何误导读者} | 第{N}章 | {影响哪些线} |

## 本卷伏笔
| 伏笔 | 埋设章节 | 预计回收 | 类型{短期/中期/长期} |
|------|---------|---------|---------------------|
```

#### 字段映射

从剧情文件提取每卷的:
- 核心矛盾 → 核心矛盾字段
- 情节点分布 → 情绪弧线 + 剧情单元(反推)
- 角色出场 → 人物弧线
- 铺垫类情节点 → 伏笔

### 细纲反推

从每章摘要(`章节/第N章_摘要.md`)提取:

| 摘要字段 | 细纲字段 | 转换方式 |
|---------|---------|---------|
| 关键事件 | 核心事件 | 直接复用 |
| 章节字数 | 字数目标 + 字数口径 | 对原文章节运行 `storyctl.py wordcount measure`,写入 `actual` 与 `visible_chars_v1` |
| 章节基调 / 情绪曲线 | 目标情绪 | 从摘要提取;缺失写 `[待补充]` |
| 第一个情节点 | 章首钩子 | 只作为证据,设计目标标 `[待补充]` |
| 爽点类情节点 | 爽点 | 从情节点类型推断;没有则写“无显性爽点 / [待补充]” |
| 情节点起承转合 | 内容概括(起因/发展/转折/高潮/结尾) | 按情节点顺序归纳;证据不足写 `[待补充]` |
| 主线/支线/任务线索 | 情节安排(主线/辅线/事件线/感情线/逻辑线) | 从剧情单元索引与摘要反推;无证据的辅线/感情线写“无”或 `[待补充]`,不得杜撰 |
| 出场角色 / 关键物件 | 人物关系和出场顺序 | 按摘要出现顺序列出;关系变化只写有证据的“前 → 后”,缺失写 `[待补充]` |
| 全部情节点 | 情节细化 / 情节点序列 | 按表格逐行写(# / 情节点 / 功能标签 / 执行边界);功能或边界不明写 `[待补充]`,不反推逐点字数配额 |
| 胜负/反转/收益损失 | 行动成本(可无)/收益归属 | 有明确证据才填写;行动成本可无、不硬造;否则 `[待补充]` |
| 最后一个情节点 / 悬念类情节点 | 结尾设定和钩子 | 收束状态可归纳;章尾钩子设计目标标 `[待补充]` |

---

## 角色状态反推

由 character-state-reverse.md 反推,详见该文件。

---

## 伏笔提取规则

从情节点中识别潜在伏笔:

### 识别模式

| 情节点类型 | 伏笔可能性 | 提取方式 |
|-----------|-----------|---------|
| 铺垫 | 高 | 直接提取为伏笔 |
| 信息揭示(部分) | 中 | 检查后续是否有呼应 |
| 物品首次出现 | 中 | 检查后续是否有使用 |
| 角色秘密 | 高 | 标记为角色伏笔 |
| 未解决的悬念 | 高 | 从章尾标记提取 |

### 状态推断

- 铺垫点在后续章节有「揭示」或「解决」类情节点 → 标记「已回收」
- 铺垫点无后续呼应 → 标记「已埋」
- 半成品小说的最后几章铺垫 → 标记「已埋」,备注「接近断点」

---

## 时间线提取规则

### 时间标记识别

从情节点和时间标记中提取:

| 标记模式 | 示例 | 提取方式 |
|---------|------|---------|
| 明确日期 | "天元三年春" | 直接记录 |
| 相对时间 | "三日后"、"半月后" | 推算绝对时间 |
| 事件间隔 | "翌日"、"次日" | 连续标记 |
| 季节标记 | "入冬"、"春暖花开" | 季节推断 |

### 排序规则

按章节顺序排列,同一章内按情节点序号排列。时间标记缺失时标注 `[推断]`。

---

## 题材定位生成

从拆文报告中提取核心发现,生成 `设定/题材定位.md`。

### 目标格式模板(设定/题材定位.md)

```markdown
# 题材定位

## 基本信息
- 题材类型:{玄幻/都市/系统/...}
- 目标平台:{Phase 1 向用户采集的目标平台;无则从拆文报告提取,仍无填 [待补充]。story-review 据此选平台 rubric}
- 核心梗:{一句话卖点}
- 微创新点:{与同类题材的差异}

## 核心梗三分法
- 表层卖点:{读者一眼看到的吸引力}
- 深层爽点:{持续追读的情绪驱动力}
- 长线钩子:{支撑全书的悬念/目标}

## 读者需求 / 情绪引擎
> 本段从 `拆文库/{导入书名}/剧情/情绪模块.md` 提炼,只记录本书已写内容的续写基线,不把本书登记成对标。

| 读者需求 | 情绪缺口 | 满足方式 | 可复现模块 | 来源 |
|---------|---------|---------|------------|------|
| {安全感/优越感/期待感/情感补偿/认知反转/陪伴感} | {缺什么} | {如何被满足} | {EM-001 等} | `[导入分析] 剧情/情绪模块.md` |

## 节奏与触发参考
> 本段从 `拆文库/{导入书名}/剧情/节奏.md` 提炼,只记录已写部分的节奏事实。

| 节奏模块 | 关键信息推进 | 情绪触动点 | 爆发节奏 | 来源 |
|---------|-------------|------------|----------|------|
| {RH/TR 编号} | {信息如何被扩写} | {触发什么感受} | {铺垫→爆发→冷却} | `[导入分析] 剧情/节奏.md` |

<!-- 仅当用户显式绑定独立外部对标时生成以下两节;未绑定时整段省略。 -->
## 对标书清单(canonical registry,可选)
主对标书: {对标书名}  # 最多 1 本;必须是独立外部参考作品
对标书列表:
  - 书名: {对标书名}
    引用强度: 主  # 主 / 辅 / 参考
    题材类型: {玄幻/都市/系统/...}
    相关性: 同题材
    用途: 文风+核心结构
  - 书名: {书名 B}
    引用强度: 辅
    题材类型: {题材}
    相关性: 同题材/弱相关
    用途: {补设定/大纲/模块,不进文风}
  - 书名: {书名 C}
    引用强度: 参考
    题材类型: {题材}
    相关性: 同题材/弱相关
    用途: {仅按预算召回摘要}

## 对标分析(派生概要)
> 完整对标数据见 `对标/` 目录;上方 registry 是权威清单。本表仅做快速概览,不可替代 `主对标书` + `对标书列表`。

| 对标书 | 相似点 | 差异点 | 可借鉴 |
|--------|-------|-------|-------|
| {对标书名} | {点} | {点} | {点} |

## 题材框架
- 八节点位置:{当前处于哪个节点}
- 关键转折节点:{列出}
```

### 字段映射

- 题材类型、核心梗、微创新点 → 从 `拆文报告.md` 基本信息与核心发现段提取
- 核心梗三分法 → 从 `拆文报告.md` 表层吸引力、爽点设计、长线悬念段提取
- 读者需求 / 情绪引擎 → 从 `剧情/情绪模块.md` 提取;缺失时停止导入并给出重跑 Stage 3+ 的修复动作
- 节奏与触发参考 → 从 `剧情/节奏.md` 提取;缺失时停止导入,不得以 `拆文报告.md`、章节摘要或 `剧情/故事线.md` 代替
- 对标书清单 → 仅登记用户显式选择、且能回溯到 `拆文库/{对标书名}/` 的外部作品;未绑定时省略,不得用 `{导入书名}` 补位
- 对标分析(派生概要) → 只总结已登记的外部对标;题材框架仍从本书导入分析生成,不得把两类来源混写

---

## 对标引用视图同步规则

本节只对用户显式绑定的外部 `{对标书名}` 生效:从 `拆文库/{对标书名}/` 同步到项目 `对标/{对标书名}/`。未绑定时不创建对标子目录;严禁把 `拆文库/{导入书名}/`、项目 `设定/` 或由其生成的文件复制到 `对标/`。

| 源路径 | 目标路径 | 同步语义 |
|-------|---------|----------|
| `拆文库/{对标书名}/剧情/节奏.md` | `{项目}/对标/{对标书名}/剧情/节奏.md` | 日更选择 `rhythm_reference` 的必备权威文件;缺失则不登记该对标 |
| `拆文库/{对标书名}/剧情/情绪模块.md` | `{项目}/对标/{对标书名}/剧情/情绪模块.md` | 日更选择 `selected_emotion_module` 的必备权威文件;缺失则不登记该对标 |
| `拆文库/{对标书名}/剧情/*.md` | `{项目}/对标/{对标书名}/剧情/*.md` | 剧情单元、故事线、散落情节等剧情资产;与权威节奏/情绪文件冲突时以后者为准 |
| `拆文库/{对标书名}/章节/*.md`(第N章_摘要.md + 黄金三章 第1-3章_深度拆解.md)| `{项目}/对标/{对标书名}/章节/*.md` | 匹配章证据,含「关键信息与扩写技法」 |
| `拆文库/{对标书名}/角色/*.md` | `{项目}/对标/{对标书名}/角色/*.md` | 角色功能位、关系与反应层参考 |
| `拆文库/{对标书名}/设定/` | `{项目}/对标/{对标书名}/设定/` | 世界观、势力、金手指等设定约束参考 |
| `拆文库/{对标书名}/拆文报告.md` | `{项目}/对标/{对标书名}/拆文报告.md` | 人类可读摘要投影 |
| `拆文库/{对标书名}/文风.md` | `{项目}/对标/{对标书名}/文风.md` | 日更文风召回必读 |

冲突规则:已登记外部对标的 `对标/{对标书名}/剧情/情绪模块.md` 和 `对标/{对标书名}/剧情/节奏.md` 是对标召回权威;`拆文报告.md`、`剧情/故事线.md` 只作为摘要投影。若摘要冲突,保留冲突说明并以权威文件为准;缺少任一权威文件时不生成半套对标视图,先修复该外部作品的拆文产物。

---

## 质量检查清单

Phase 3-L 迁移完成后执行:

- [ ] 正文文件数 = 源文件章节数
- [ ] 主要角色(主角 + 核心配角)文件已创建
- [ ] 关系.md 非空
- [ ] 大纲.md 有卷级结构
- [ ] 每章细纲已生成
- [ ] `_tracking-state.json.imported_through_chapter` 等于最后完整导入章
- [ ] `追踪/伏笔.md` 每个 ID 至多一行,未来尚未埋设的设计没有混入
- [ ] `_tracking-state.json.timeline` 已登记关键事实与读者认知,`读者已知.md` 无真相泄露
- [ ] `追踪/角色状态/{角色名}.md` 已覆盖全部核心角色并对齐 `character-state-reverse.md`
- [ ] 追踪/逐章记录/ 空目录已创建,且没有为导入章伪造日更记录
- [ ] `追踪/上下文.md` 顶层恰好 固定 7 栏且 ≤12288 字节
- [ ] `tracking_commit.py check` 通过,`_tracking-state.json` 与全部派生视图一致
- [ ] 散落情节已合并到相关卷纲或大纲附录
- [ ] 卷划分已经用户确认(原文无明确卷界时必检)
- [ ] `拆文库/{导入书名}/` 未被复制到 `对标/`,本书未登记为自身对标
- [ ] 若绑定外部对标,`拆文库/{对标书名}/` 与 `对标/{对标书名}/` 名称和来源一致,两个主产物均已同步;否则报告修复动作但不回滚本书工程
references/structure-mapping-short.md
# 结构迁移映射规则(短篇)

Phase 3-S 短篇结构迁移的详细映射规则。将 `拆文库/{导入书名}/` 短篇拆文产物转换为 `{短篇标题}/` 短篇工程结构,供 `story-short-write` Phase 3 无缝接手续写。

> 长篇迁移规则见 `structure-mapping-long.md`。

> **名称边界**:`{导入书名}` 是用户自己的待续写短篇;`{对标书名}` 是另行选择的外部参考作品。不得把本篇拆文结果复制到本篇 `对标/`,也不得把本篇分析写成“对标摘要”。

---

## 与长篇的关键差异

| 维度 | 短篇 | 长篇 |
|------|------|------|
| 正文 | 单文件 `正文.md`,**不切章** | `正文/第XXX章_章名.md` 多文件 |
| 追踪目录 | **不产** `追踪/` | 产 `追踪/`(`_tracking-state.json`、续写状态卡、逐章记录、角色快照、伏笔视图、双视角时间线) |
| 角色状态 | **不产**角色追踪 | 核心角色各产 `追踪/角色状态/{角色名}.md`,由 `character-state-reverse.md` 反推 |
| 大纲体系 | **不产** 卷纲、细纲 | 产 `大纲/卷纲_第X卷.md` + `大纲/细纲_第N章.md` |
| 大纲目录 | **不产** `大纲/` 目录 | 产 |
| 续写衔接 | story-short-write Phase 3(逐场景写作) | story-long-write 日更循环 |

---

## 映射总览

| 拆书产物 | 短篇工程文件 | 转换方式 |
|---------|------------|---------|
| 原文全文(`拆文库/{导入书名}/原文/`) | `{标题}/正文.md` | 单文件迁移,按下方『正文.md 格式规范』规范化,不重写内容 |
| `拆文报告.md` 的故事核/题材/结构字段 | `{标题}/设定.md`(核心框架区) | 反推本书核心框架;不继承报告中可能存在的自对标登记 |
| `情节节点.md` 的功能分段 | `{标题}/小节大纲.md` | 反推小节大纲(按开头段/铺垫段/升级段/反转段/结尾段,钩子字段标 `[待补充]`) |
| `拆文报告.md` + `写作手法.md` | `{标题}/设定.md`(本书续写基线区) | 把已写内容的结构、情绪、反转和既有写法整理为内部续写上下文 |
| `拆文库/{对标书名}/` | `{标题}/对标/{对标书名}/` | 可选:仅同步用户显式绑定的外部参考作品 |

---

## 设定.md 反推规则

目标文件使用本文件定义的当前核心框架模板,分两个区块:

### 区块一:核心框架

从 `拆文报告.md` 的故事核、结构划分、人物分析等字段提取,填入以下模板:

```markdown
## 短篇核心框架

### 基本信息
- 标题:{导入书名}
- 目标字数:{原文实际字数} 字
- 目标平台:{从拆文报告提取,无则填 [待补充]}
- 情绪目标:{从拆文报告「情感线/爆点分析」提取读者预期感受}

### 一句话梗概
{主角 + 困境 + 反转 + 情绪落点(从拆文报告故事核提取)}

### 核心反转
- 反转类型:{从拆文报告反转分析提取:身份反转/视角反转/动机反转/时间线反转}
- 反转内容:{一句话描述}
- 铺垫线索:{从情节节点中的「铺垫」类节点提取,至少 3 个;不足时标 [待补充]}

### 情绪设计
- 开头情绪:{从情感曲线第一个节点提取}(强度 {1-10})
- 中段情绪:{中段情感节点}(强度 {1-10})
- 反转情绪:{反转节情感峰值}(强度 {1-10})
- 结尾情绪:{结尾节情感}(强度 {1-10})

### 人设速写
- 主角:{一句话人设,从人物分析提取}
- 关键角色:{一句话人设}
- 关系:{他们之间的关系}
```

> 不确定字段一律加 `[待补充]` 标记,不留空字段。

### 区块二:本书续写基线

从 `拆文库/{导入书名}/拆文报告.md` 和 `写作手法.md` 提取,汇总为本书内部续写基线:

```markdown
## 本书续写基线

### 故事结构
{从拆文报告「功能分段」字段提取,概述各段功能}

### 情绪节奏
{从情感曲线/爆点分析提取,描述情绪走势与峰值位置}

### 核心反转机制
{从反转机制分析提取,含铺垫路径}

### 可复用写作手法
{从写作手法.md 或拆文报告「可复用结构」字段提取,≥3 条}
```

---

## 小节大纲.md 反推规则

从 `情节节点.md` 的功能分段结构提取,映射到短篇段-小节结构。

### 段级映射

| 短篇段名 | 对应情节节点功能段 | 说明 |
|---------|----------------|------|
| 开头段 | 开端(引入主角/世界/困境) | 通常 1-2 个小节 |
| 铺垫段 | 发展前期(冲突积累/伏笔布置) | 通常 2-4 个小节 |
| 升级段 | 发展后期(冲突升级/误解加深) | 通常 2-3 个小节 |
| 反转段 | 高潮(核心反转引爆) | 通常 1-2 个小节,情绪峰值 |
| 结尾段 | 结局(收束/情绪落点) | 通常 1-2 个小节 |

### 小节条目模板

```markdown
## 开头段

### 小节 1
- 核心事件:{从情节节点提取该分段的主要情节点}
- 情绪目标:{对应情感曲线节点的情绪词}
- 章首钩子:[待补充]
- 章尾钩子:[待补充]
- 参考字数:{按原文对应段落字数估算}
```

> 钩子字段标 `[待补充]`——无法可靠提取,留给用户或续写时填写。

### 小节数量推算

按原文实际字数估算:`总字数 ÷ 1000 ≈ 小节数`(短篇参考区间 8-15 个小节)。小节数与正文实际段落结构一致是质量清单必检项。

---

## 正文.md 格式规范

将原文迁移为单文件 `正文.md`,格式按 `format-and-structure.md` 规范化:

| 规范项 | 要求 |
|--------|------|
| 小节标记 | `###1.` `###2.` `###3.`(短篇通用格式;用户指定平台时按 `format-and-structure.md` 平台覆盖表切换) |
| 段落划分 | 按戏剧单元/镜头/一件事结束自然断段;不按固定字数强拆;完整推理、氛围、情绪链可保留稍长段,避免通篇同长度或碎成提纲 |
| 段间空行 | 正文相邻段落之间**只允许一个换行符 `\n`**,不得出现空行或 `\n\n` |
| 对话引号 | 默认 `""` 半角双引号;知乎盐言平台可用 `「」` |
| 缩进 | 无缩进,不用全角/半角空格 |
| Markdown | 正文段落中不使用 `**` `*` `#` `---` 等 Markdown 语法(仅小节标记除外) |

**原文内容不改动**,只做格式规范化(分段、引号统一、小节标记添加)。

---

## 外部对标引用视图(可选)

仅当用户显式绑定独立外部 `{对标书名}` 时,将 `拆文库/{对标书名}/` 复制为 `{标题}/对标/{对标书名}/`(跳过 `_archive_*/`,归档快照不进对标视图),供续写阶段加载:

```
{标题}/对标/{对标书名}/
├── 原文/
├── 拆文报告.md
├── 情节节点.md
├── 写作手法.md
└── _meta.json
```

此视图为可选项,没有外部对标时不创建。复制前必须确认来源是 `拆文库/{对标书名}/`,不得使用 `拆文库/{导入书名}/` 或本篇 `设定.md` 填充。

---

## 质量检查清单

Phase 3-S 迁移完成后执行:

- [ ] `正文.md` 单文件存在且格式符合 `format-and-structure.md`(小节标记、段落划分/主语节奏、段间仅单换行、引号格式)
- [ ] `设定.md` 含核心框架区块 + 本书续写基线区块,且核心框架符合本文件模板
- [ ] `小节大纲.md` 节数与正文实际段落结构一致
- [ ] 所有 `[待补充]` 标记已添加(不确定字段不留空)
- [ ] 未误建 `追踪/`、`大纲/`、`正文/` 等长篇专属目录
- [ ] `拆文库/{导入书名}/` 未被复制到本篇 `对标/`;若绑定外部对标,来源与 `对标/{对标书名}/` 目录名一致
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/check-outline-contract.js
#!/usr/bin/env node
/**
 * Deterministic 细纲 structural verifier for story-long-write.
 *
 * Usage:
 *   node scripts/check-outline-contract.js --json <细纲路径...>
 *   node scripts/check-outline-contract.js --json --project <书目录> --chapter N
 * Exit: 0 = pass, 1 = blocking contract failures, 2 = invalid invocation.
 *
 * Scope is structural only: it decides whether the blueprint carries the fields,
 * subsections and table shape the authoritative template names. It never judges
 * whether a value is good. The contract itself sets this granularity —
 * artifact-protocols.md 要求未知字段写 `[待补充]`,所以字段必须在场,值可以未知。
 */

'use strict'

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

// 权威模板:references/workflow-setup.md「细纲(全书每章)」
const FIELDS = [
  '核心事件', '字数目标', '字数口径', '阶段位置', '单元ID/位置', '目标情绪',
  '主角目标/关键选择', '章节定位', '本章结构公式', '章首钩子', '爽点',
  '本章禁止提前释放', '契约风险',
]
const SUBSECTIONS = ['内容概括', '情节安排', '人物关系和出场顺序', '情节细化']
const FIVE_ACT = ['起因', '发展', '转折', '高潮', '结尾']
const PLOT_HEADER_FIRST = /^(?:#|序号)$/
// 这两个字段实测直接影响正文质量,必须有实际内容
const INTENT_FIELDS = ['目标情绪', '主角目标/关键选择']
const CALIBER = 'visible_chars_v1'

function fieldPattern(name) {
  // 允许 -/*/+ 项目符号、可选 ** 加粗、全角或半角冒号
  const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
  return new RegExp(`^\\s*[-*+]\\s*\\*{0,2}${escaped}\\*{0,2}\\s*[::]`, 'm')
}

function readUtf8(file) {
  try {
    const text = fs.readFileSync(file, 'utf8').replace(/^/, '')
    return { ok: text.trim().length > 0, text }
  } catch (error) {
    return { ok: false, text: '', error: error.message }
  }
}

function makeCheck(id, ok, file, evidence, expected, repair) {
  return {
    id,
    ok,
    severity: 'blocking',
    file,
    evidence,
    expected,
    references: ['references/workflow-setup.md', 'references/artifact-protocols.md'],
    repair,
  }
}

function parseTableRow(line) {
  const trimmed = line.trim()
  if (!trimmed.startsWith('|') || !trimmed.endsWith('|')) return null
  return trimmed.slice(1, -1).split('|').map((cell) => cell.replace(/\*\*/g, '').replace(/`/g, '').trim())
}

function verify(file) {
  const name = path.basename(file)
  const read = readUtf8(file)
  const checks = []

  checks.push(makeCheck(
    'outline.readable',
    read.ok,
    name,
    read.ok ? '文件存在且非空' : (read.error || '文件为空'),
    '细纲文件存在且非空',
    '只补建缺失的细纲文件,不改动同批其他章。'
  ))
  if (!read.ok) return report(file, checks)
  const text = read.text

  const missingFields = FIELDS.filter((field) => !fieldPattern(field).test(text))
  checks.push(makeCheck(
    'outline.required-fields',
    missingFields.length === 0,
    name,
    missingFields.length ? `缺字段:${missingFields.join('、')}` : `${FIELDS.length} 个字段齐全`,
    `按权威模板列出全部字段:${FIELDS.join('、')};值未知时写 [待补充],不杜撰剧情`,
    '只补报告里缺的字段行;确实还定不下来的写 [待补充],不为补字段新增副线或人物关系。'
  ))

  // 隔离实验(同章、同写作流程,只改细纲):只补这两个字段就能复现补齐全部字段的收益,
  // 盲评 3/3 胜过不补;补满五个字段与只补这两个不可区分。所以这两个字段不接受占位符,
  // 其余字段仍按契约允许 [待补充]。
  const hollow = INTENT_FIELDS.filter((field) => {
    const match = text.match(new RegExp(`^\\s*[-*+]\\s*\\*{0,2}${field.replace('/', '\\/')}\\*{0,2}\\s*[::]\\s*(.*)$`, 'm'))
    if (!match) return false
    const value = match[1].replace(/\[待补充\]/g, '').replace(/[\s、,,。;;]/g, '')
    return value.length === 0
  })
  checks.push(makeCheck(
    'outline.intent-fields-substantive',
    hollow.length === 0,
    name,
    hollow.length ? `只有占位符,没有实际内容:${hollow.join('、')}` : '目标情绪与主角目标/关键选择都写了实际内容',
    '目标情绪写清前状态→后状态;主角目标/关键选择写清本章要什么、必须做出的判断。这两项不接受 [待补充]',
    '只把这两个字段替换成本章的实际情绪变化与实际取舍;其余字段不动。'
  ))

  const missingSubs = SUBSECTIONS.filter((sub) => !new RegExp(`^#{3,4}\\s*${sub}`, 'm').test(text))
  checks.push(makeCheck(
    'outline.subsections',
    missingSubs.length === 0,
    name,
    missingSubs.length ? `缺小节:${missingSubs.join('、')}` : '四个小节齐全',
    '包含 内容概括 / 情节安排 / 人物关系和出场顺序 / 情节细化 四个小节',
    '只补缺失的小节标题及其条目,不重写已成立的内容。'
  ))

  const missingActs = FIVE_ACT.filter((act) => !fieldPattern(act).test(text))
  checks.push(makeCheck(
    'outline.five-act',
    missingActs.length === 0,
    name,
    missingActs.length ? `五段式缺:${missingActs.join('、')}` : '五段式齐全',
    '内容概括写全 起因 / 发展 / 转折 / 高潮 / 结尾',
    '只补缺的那一段,不改其余四段。'
  ))

  const lines = text.split(/\r?\n/)
  let header = null
  for (const line of lines) {
    const cells = parseTableRow(line)
    if (cells && cells.length === 4 && PLOT_HEADER_FIRST.test(cells[0])) {
      header = cells
      break
    }
  }
  const headerOk = Boolean(header) && header[2].includes('功能标签') && header[3].includes('执行边界')
  checks.push(makeCheck(
    'outline.plotpoint-table',
    headerOk,
    name,
    header ? `表头:${header.join(' | ')}` : '未找到 | # | 情节点 | 功能标签 | 执行边界 | 表头',
    '情节细化使用四列表格:# / 情节点(谁做了什么) / 功能标签 / 执行边界',
    '只把情节点序列改成四列表格,逐点补功能标签与执行边界;不增删情节点本身。'
  ))

  const targetMatch = text.match(/字数目标\s*[::]\s*(?:约\s*)?([\d,,]+)/)
  const target = targetMatch ? Number(targetMatch[1].replace(/[,,]/g, '')) : null
  const caliberOk = new RegExp(`字数口径\\s*[::]\\s*${CALIBER}`).test(text)
  checks.push(makeCheck(
    'outline.wordcount-target',
    Boolean(target) && Number.isFinite(target) && target >= 500 && target <= 20000 && caliberOk,
    name,
    `字数目标:${target === null ? '未识别' : target};字数口径 ${CALIBER}:${caliberOk}`,
    `字数目标为 500-20000 的正整数,并声明 字数口径:${CALIBER}`,
    '只补字数目标或字数口径行,不调整情节安排。'
  ))

  return report(file, checks)
}

function report(file, checks) {
  const failures = checks.filter((check) => !check.ok)
  return {
    schema_version: 1,
    verifier: 'story-long-write.outline-contract',
    file: path.resolve(file),
    ok: failures.length === 0,
    checks,
    failures,
    repair_scope: failures.map((failure) => ({
      id: failure.id,
      file: failure.file,
      evidence: failure.evidence,
      expected: failure.expected,
      references: failure.references,
      repair: failure.repair,
    })),
  }
}

function resolveChapter(project, chapter) {
  const dir = path.join(project, '大纲')
  let entries
  try {
    entries = fs.readdirSync(dir)
  } catch (error) {
    return { error: `无法读取 ${dir}:${error.message}` }
  }
  const wanted = Number(chapter)
  const hit = entries.find((entry) => {
    const match = entry.match(/^细纲_第0*(\d+)章.*\.md$/)
    return match && Number(match[1]) === wanted
  })
  if (!hit) return { error: `${dir} 下没有第 ${wanted} 章细纲` }
  return { file: path.join(dir, hit) }
}

function parseArgs(argv) {
  const files = []
  let project = null
  let chapter = null
  for (let index = 0; index < argv.length; index++) {
    const arg = argv[index]
    if (arg === '--json') continue
    if (arg === '--project' || arg === '--chapter') {
      if (index + 1 >= argv.length || argv[index + 1].startsWith('--')) return null
      const value = argv[++index]
      if (arg === '--project') project = value
      else chapter = value
      continue
    }
    if (arg.startsWith('--')) return null
    files.push(arg)
  }
  if (project || chapter) {
    if (!project || !chapter || files.length || !/^\d+$/.test(chapter)) return null
    return { project, chapter }
  }
  if (!files.length) return null
  return { files }
}

function main(argv) {
  const parsed = parseArgs(argv)
  if (!parsed) {
    process.stderr.write('用法: node scripts/check-outline-contract.js --json <细纲路径...> | --json --project <书目录> --chapter N\n')
    return 2
  }
  let targets = parsed.files
  if (!targets) {
    const resolved = resolveChapter(parsed.project, parsed.chapter)
    if (resolved.error) {
      process.stderr.write(`${resolved.error}\n`)
      return 2
    }
    targets = [resolved.file]
  }
  const reports = targets.map((file) => verify(file))
  const ok = reports.every((entry) => entry.ok)
  process.stdout.write(`${JSON.stringify(reports.length === 1 ? reports[0] : reports, null, 2)}\n`)
  return ok ? 0 : 1
}

if (require.main === module) process.exitCode = main(process.argv.slice(2))

module.exports = { verify, FIELDS, SUBSECTIONS, FIVE_ACT }
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-import
version: 1.0.0
description: "逆向导入已有小说。将已写好的小说(半成品或完本)反向解析为标准项目目录结构,兼容 story-long-write / story-short-write 后续写作流程;内部复用 story-long-analyze / story-short-analyze 的拆解管道,按篇幅自动分流。触发方式:/story-import、「导入小说」「反向解析」「导入」「把我的书导进来」。"
metadata: {"openclaw":{"source":"https://github.com/zenstory-ai/oh-story-claudecode"}}
---
# story-import:逆向导入已有小说

你是小说项目逆向工程师。导入按篇幅分流:长篇走 Phase 3-L,短篇走 Phase 3-S。

**交付物是写作工程**:把作者已有的书重建为可续写的**写作工程**(项目结构 + 拆文库分析资产)。`拆文库/{导入书名}/` 是重建工程的数据源,不能当成用完即弃的中间产物,也不能替代交付物本身——交付物应让作者能直接续写。执行时以「建工程」为可见目标,别把「拆文」当成终点或对外标签。

---

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

## 核心原则

### 名词与目录边界(全流程硬约束)

- `{导入书名}`:用户自己已经写到一半或已经完本、现在要重建为工程的小说;它的分析源固定为 `拆文库/{导入书名}/`。
- `{对标书名}`:用户另行选择的外部参考作品;它必须是独立拆解产物,来源固定为 `拆文库/{对标书名}/`,且不得指向本次导入源。
- `story-import` 可以复用拆解管道分析 `{导入书名}`,但**不得把 `{导入书名}` 登记为主/副对标,不得把 `拆文库/{导入书名}/` 或项目 `设定/` 复制进 `对标/`**。
- 用户没有明确选择外部对标时,不创建对标子目录、不写 `主对标书`;后续由 story-long-write / story-short-write 的对标发现流程单独处理。

### 原则 1:先分析后迁移

先用拆解管道完整拆解小说(输出到 `拆文库/{导入书名}/`),再将分析结果迁移为项目结构。该目录保存本书导入分析,保留不丢弃,但不属于外部对标视图。

### 原则 2:复用不重复

深度分析阶段调用现成的拆解管道,不重新发明:长篇运行 `/story-long-analyze` 的完整拆解管道,短篇运行 `/story-short-analyze` 的拆解管道。拆解方法论与输出模板由对应 analyze skill 自带,story-import 不执行拆解方法论、不维护这些文件。

---

## Phase 1:确认导入源

### Step 1:导入续写入口顺序(先答用户的流程问题)

当用户问"导入续写先走 story-setup 还是 story-import"、"已有小说怎么续写"、"导入流程"这类流程问题时,先直接给出结论,再继续收集原文:

1. **推荐顺序**:先 `/story-setup`(部署 hooks/agents/AGENTS),新开/刷新会话后运行 `/story-import`,最后用 `/story-long-write 日更/写第N章` 续写。
2. **也可以直接 `/story-import`**:本 skill 会在进入深度分析前检测 `.story-deployed` 与专业 agent;未部署时会给出"先去 setup"或"继续导入(串行降级)"两种选择。
3. **已导入过的当前协议项目**(书名目录下有 `追踪/_tracking-state.json`):不要重复跑完整导入;直接进入书名目录,确认 `.active-book` 指向正确书目,再用 `/story-long-write 日更` 或 `/story-long-write 写第N章`。
4. **v0.7.2 及更早的旧追踪项目**(有 `追踪/` 和正文,但没有 `追踪/_tracking-state.json`):日更会停下要求重新导入,但**不需要重跑全书拆解**。只重建追踪即可,见下方「旧追踪项目迁移」。

这段结论必须出现在任何导入源追问之前,避免用户只想确认流程却被直接要求贴原文。

#### 旧追踪项目迁移

书名目录下有 `追踪/` 与正文、但没有 `追踪/_tracking-state.json` 时,项目停在 v0.7.2 及更早的追踪结构上。正文和 `设定/`、`大纲/`、`拆文库/` 都不受影响,**只需重建 `追踪/`**,不重跑 Phase 2 拆解、不碰正文:

1. 数清最后一个完整章号 `N`(`正文/第NNN章_*.md` 的最大值)。
2. 从旧 `追踪/` 现有文件(角色状态、伏笔、时间线等,文件名按项目实际情况)和最近 3-5 章正文,重建当前状态:核心角色快照、未回收伏笔、已揭示时间线事件、长期约束、下一章承诺。角色快照的反推方法见 [references/character-state-reverse.md](references/character-state-reverse.md)。
3. 按 [references/tracking-transaction.md](references/tracking-transaction.md) 的初始化事务格式构造 JSON,`last_chapter` 写 `N`(第 1..N 章不伪造逐章记录),执行 `tracking_commit.py init`。
4. `init` 会把旧追踪结构按原样整体移入 `追踪/_旧追踪存档/` 再建当前协议——旧内容不删除、不参与解析,留给作者查阅。
5. 跑 `tracking_commit.py check` 确认通过,再回 `/story-long-write 日更` 续写。

重建结果以第 2 步的证据为准;拿不准的字段留空或写进 `continuity_risks`,不杜撰。用户明确要求重拆全书时才走完整 Phase 2。

问用户:**「你要导入哪本书?请提供文件路径或直接贴文本。」**

### Step 2:确认意图(写作工程 vs 仅拆文库)

默认目标是**完整写作工程**(可续写)。若用户意图不明确——是要可续写的工程,还是只要一份拆文库分析——**主动询问**,不要默认:

> 「你是想把这本书做成可续写的写作工程(设定/大纲/正文/追踪,能接着写第 N+1 章),还是只要一份拆文库分析?」

- 要可续写工程 → 走完整 story-import(Phase 2 拆 + Phase 3 迁移)。
- 只要分析 / 拆文库 → 直接用 `/story-long-analyze`(短篇 `/story-short-analyze`),到拆文库为止,不进 Phase 3 迁移。

### Step 3:输入方式识别

```
用户提供路径?
├─ 单文件路径(.txt/.md)
│   └─ 按章节分隔符自动切分
├─ 目录路径
│   └─ 按文件名排序,合并处理
└─ 无路径 → 用户直接贴文本?
              ├─ 是 → 保存到临时文件后处理
              └─ 否 → 提示用户提供源文件
```

### Step 4:基本信息确认

1. **自动检测**:从文本中识别书名(如果有)、总章数、总字数、章节格式
2. **用户确认**:
   - 导入书名:{自动检测或用户输入}
   - 题材类型:{用户提供}
   - 目标平台:{起点/番茄/晋江/其他}
   - 是否完本:{是/否(半成品写到第N章)}
   - **篇幅类型**:长篇 / 短篇 —— 按 [references/length-routing.md](references/length-routing.md) 自动检测(用户显式声明 > 结构信号 > 字数兜底),并向用户复述检测结果请其确认。判定结果决定 Phase 3 走长篇还是短篇路径。
   - **最后一章是否完整**:完整章 / 残稿(写了一半)。若是残稿,提示用户并把「残稿到第 N 章」记入上下文,让用户决定是「基于残章续写」还是「先补完再导入」。story-import 只记录用户决定,不替用户选。
3. **外部对标(可选、与导入源分离)**:用户已经明确指定外部对标时,记录 `{对标书名}` 并确认 `拆文库/{对标书名}/` 是该参考作品的独立拆解产物;不得把 `{导入书名}` 或本次刚生成的拆文目录当候选。用户未指定时不追加提问,记为“未绑定”,后续交给写作 skill 的对标发现流程。
4. **输出确认**:向用户展示检测到的章节范围、字数、判定的篇幅类型、最后一章状态,以及“外部对标:{对标书名/未绑定}”,确认后开始分析。

### Step 5:环境检测前置

在进入 Phase 2 之前,先检测项目是否已部署 story-setup 基础设施:

- 先读取 `.story-deployed` 并执行顶部 Spawn 版本门禁;旧版 `chapter-extractor` 文件即使仍在磁盘上也不可复用。
- 只有 `agents_version: 28` 通过后,才在当前运行时的 canonical 目录检查 Phase 2 `chapter-extractor`:Claude/OpenCode/Antigravity 为同名 Markdown,Codex 为同名 TOML。
- 如果 `.story-deployed` 的 `target_cli` 包含 `zcode`,项目 agents 缺失是 ZCode 3.3.4 的预期状态:不要提示重复部署,直接以串行 solo/direct 进入分析并报告 fallback。

**部署标记缺失、版本无效/过期,或当前端的 agent 不可用,且不是已部署 ZCode 项目时**,提示用户:

> 「检测到当前项目尚未部署写作基础设施。建议先运行 `/story-setup` 再回来导入,否则深度分析阶段无法使用并行 chapter-extractor agent。」

给用户两个选择:

1. **先去 setup**:暂停导入,运行 `/story-setup`,部署完成后重新触发 `/story-import`;
2. **继续导入**:接受 Phase 2 降级为串行处理(长篇逐章摘要不并行,速度较慢,但产物完整)。

用户选择记入上下文,Phase 2 据此决定是否走并行模式。

### Step 6:原文备份

原文备份由 Phase 2 调用的 analyze 拆解管道负责(analyze 管道前置步骤会把原文复制/保存到 `拆文库/{导入书名}/原文/`,对应 story-long-analyze 与 story-short-analyze 的「原文备份(管道前置步骤)」)。Phase 1 只需确认源文件就绪(路径有效或文本已拿到),不在此处单独备份,避免与 analyze 管道重复备份逻辑。

---

## Phase 2:深度分析

按 Phase 1 判定的篇幅类型,调用对应 analyze skill 的**完整拆解管道**;不要做「复用方法论」式的半流程,要驱动整条管道跑完,拿到全套结构化产物。

| 篇幅 | 调用的拆解管道 | 产物目录 |
|------|--------------|---------|
| 长篇 | story-long-analyze 的完整管道(Stage 0-6) | `拆文库/{导入书名}/` |
| 短篇 | story-short-analyze 的拆解管道(Stage 2-6) | `拆文库/{导入书名}/` |

### 调用契约

#### 长篇:自动续跑过 Stage 1 停靠点

story-long-analyze 在 Stage 0+1(黄金三章)后会**自动停靠**并用 AskUserQuestion 询问是否继续全量拆解(对应 story-long-analyze 的「Stage 1 停靠点」)。但导入场景需要 Stage 2-6 的全套产物(逐章摘要 / 聚合分析 / `剧情/节奏.md` / `剧情/情绪模块.md` / 设定关系 / 汇总报告 / 文风),缺一不可——否则 Phase 3 迁移会拿到半成品。

**当前拆文契约**:`_progress.md` 必须是 `schema_version: 2`,且 `剧情/节奏.md` 与 `剧情/情绪模块.md` 是导入必备权威产物。任一缺失都先修复或重跑对应 Stage,不得用摘要文件拼出看似完整的导入工程。

因此调用 story-long-analyze 时**必须在一开始就以「完整拆解、一次跑完、不要停下询问」模式驱动管道**,命中其「跳过询问」路径(用户开头明确说「完整拆解 / 一次跑完 / 系统拆解 / 别问」时不停靠),让管道自动从 Stage 2 续跑到 Stage 6。

- 措辞示例:启动深度分析时声明「以『完整拆解、一次跑完、不要停下询问』模式拆解本书,确保 Stage 2-6 全部产出」。
- **兜底**:若运行环境实际仍停在 Stage 1 询问处,story-import 自动选择「继续全量拆解」,**绝不把停靠询问甩给用户**。
- 环境检测(Phase 1)发现未部署 chapter-extractor agent 且用户选择「继续导入」时,Stage 2 逐章摘要降级为串行处理,产物仍完整,仅速度变慢。

#### 短篇:单一全量管道

story-short-analyze 的拆解管道(Stage 2-6)本身**无 Stage 1 停靠点**,一次跑完即可。它的 Phase 1 四个 Step 都要跑,按下表的导入场景取值执行,不整段跳过:

| Phase 1 步骤 | 导入场景下的处理 |
|-------------|----------------|
| Step 1:拿到原文 | 用 story-import Phase 1 已确认的源文件,不重新问 |
| Step 2:字数检查(长短篇路由) | 篇幅已在 story-import Phase 1 判定并经用户确认,直接答「按短篇继续」,不重新路由 |
| Step 3:题材识别 | **照常跑**,题材标尺必须加载;story-import Phase 1 Step 4 已确认的题材类型直接代入,不重复提问 |
| Step 4:续跑检查(`拆文库/{导入书名}/_meta.json` 已存在时三选一) | 先看旧产出是否可直接复用:`stages_completed` 已含 6 且 `拆文报告.md` / `情节节点.md` / `写作手法.md` / `原文/` 均非空、来源与本次导入源一致 → 直接进 Phase 3,不重跑也不归档。否则本轮首次进入 Phase 2 → 按 (a) 覆盖:先把旧产出归档到 `拆文库/{导入书名}/_archive_{时间戳}/`,再从 Stage 2 重跑;同一轮导入内重试同一本书 → 按 (b) 续跑。不把三选一甩给用户,也不跳过归档 |

`_meta.json` 的 `genre_detected` 由 Step 3 产出,是拆文契约的阻断级必填字段,下游 story-short-write 靠它选题材标尺——**不要跳过 Step 3 直接从原文备份起跑**。

- 措辞示例:启动深度分析时声明「《{导入书名}》篇幅已确认为短篇(题材 {题材类型},全文约 {N} 字),Step 2 直接按短篇继续,Step 4 按覆盖并归档处理,题材识别照跑,确保 Stage 2-6 全部产出」。
- **兜底**:若运行环境仍抛出「此文字数 {N} 偏长,建议改用 `/story-long-analyze`」或灰区提问「介于短/长之间,按短篇还是长篇拆?」,一律按 Phase 1 已锁定的判定逐字回「按短篇继续」,**绝不把路由询问甩给用户**。

### 输出目录

#### 长篇拆文库结构

长篇分析输出到 `拆文库/{导入书名}/`,与 story-long-analyze 拆解管道完全一致:

```
拆文库/{导入书名}/
├── 原文/
│   └── 原文.txt          # 扩展名随源文件;对话直接贴入的文本存为 原文.md
├── 概要.md
├── 章节/
│   ├── 第1章_深度拆解.md
│   ├── 第1章_摘要.md
│   └── ...               # 每章同时有 第N章_深度拆解.md 和 第N章_摘要.md
├── 快速预览.md
├── 角色/
│   ├── {角色名}.md
│   └── 角色关系.md
├── 剧情/
│   ├── {剧情标题}.md
│   ├── 故事线.md
│   ├── 节奏.md          # 关键信息推进 / 情绪触动点 / 爆发节奏
│   ├── 情绪模块.md      # 读者需求 / 情绪引擎 / 可复现模块
│   └── 散落情节.md
├── 设定/
│   ├── 世界观/         # 背景设定.md / 力量体系.md / 地理.md / 金手指.md(子目录形态)
│   └── 势力/           # {势力名}.md(每势力一文件)
├── 拆文报告.md
├── 文风.md          # Stage 6 文风:写作技法视图 + 原文范例锚点
└── _progress.md
```

#### 短篇拆文库结构

短篇分析输出到 `拆文库/{导入书名}/`,与 story-short-analyze 拆解管道一致:

```
拆文库/{导入书名}/
├── 原文/
│   └── 原文.txt          # 扩展名随源文件;对话直接贴入的文本存为 原文.md
├── 拆文报告.md
├── 情节节点.md
├── 写作手法.md
└── _meta.json           # 管道元数据 + 结构计数(下游 story-short-write 必读)
```

### 长篇完整管道(Stage 0-6)

> 管道详细说明见 story-long-analyze(运行 `/story-long-analyze`),此处仅列概要。

| 阶段 | 名称 | 输入 | 输出 | 完成标志 |
|------|------|------|------|----------|
| 0 | 概要提取 | 原始文本 | 概要.md + 章节索引 | 章节结构识别完成 |
| 1 | 黄金三章 | 前 3 章原文 | 第1章_深度拆解.md / 第2章_深度拆解.md / 第3章_深度拆解.md → **停靠产出快速预览.md**(导入场景自动续跑,不停下询问) | 3 章拆解完成 |
| 2 | 逐章摘要 | 分块章节文本 | 章节摘要.md(含情节点+角色+**关键信息与扩写技法**)。每章10-40情节点(密度150-200字/个,按字数动态调节)。角色过滤(龙套不提取、别名归类)。**并行 chapter-extractor agent 模式**(未部署 agent 时降级串行)。**计数验证:摘要数 == 章节数**。 | 所有章节处理完成 |
| 3 | 聚合分析 | 全部章节摘要 | `剧情/*.md` + `剧情/README.md` + `剧情/故事线.md` + **`剧情/节奏.md` + `剧情/情绪模块.md`**。**故事框架识别**(前置)。**两步法剧情聚合**(先从摘要识别剧情大纲,再按大纲分配情节点)。**关键信息推进索引**、**情绪触动点与爆发节奏**、**读者需求 / 情绪引擎 / 可复现模块**。**角色合并**(跨章节去重+别名归一)。**角色分级**(主角/反派/核心配角/功能角色)。**散落情节兜底**(6步,含覆盖率验证)。**质量检查**(置信度>=0.85/覆盖率85%-95%/重叠率<=35%)。 | 质量检查通过 |
| 4 | 设定+关系 | 阶段 3 合并后角色数据+情节点 | 设定/*.md + 角色/*.md。**两阶段角色模型**。**别名解析**(置信度≥0.85自动合并)。 | 设定和关系提取完成 |
| 5 | 汇总报告 | 全部输出 | 拆文报告.md(含「读者需求 / 情绪引擎」「关键信息与扩写技法总览」「节奏与情绪触动点」「可复现模块」,并指向 `剧情/节奏.md` / `剧情/情绪模块.md`) | 报告生成完成 |
| 6 | 文风 | 拆文报告.md + 章节/第1-3章_深度拆解.md + 章节/*_摘要.md + 原文/原文.txt | 文风.md(本书历史写法分析) | 文风落盘 `拆文库/{导入书名}/文风.md`,保留为导入分析,不复制到本书 `对标/` |

### 短篇拆文管道

> 管道详细说明见 story-short-analyze(运行 `/story-short-analyze`),此处仅列概要。

短篇为单一全量管道(Stage 2-6 严格串行),产物落盘 `拆文库/{导入书名}/`:Stage 2 结构+情节节点 → Stage 3 情感线+爆点 → Stage 4 反转+写作手法 → Stage 5 人物+开头结尾 → Stage 6 综合评估,最终汇总为 `拆文报告.md`、`情节节点.md`、`写作手法.md`,另有 `_meta.json` 记管道元数据与结构计数。

长篇分块沿用 story-long-analyze:Stage 2 用 chapter-extractor agent 并行,其余阶段按该 skill「分块策略」的章数阈值执行,story-import 不另定一套。

### 恢复机制

- 中断时通过进度文件追踪进度
- 新会话读取进度文件定位断点
- 从断点所在块的起始章节恢复
- 长篇进度文件格式沿用 story-long-analyze 拆解管道的进度段落约定,包含当前阶段、最后处理章节、已完成阶段列表、更新时间

### 质量检查

长篇阶段 3-4 完成前执行质量检查(置信度 >= 0.85,覆盖率 85%-95%,重叠率 <= 35%),由 story-long-analyze 拆解管道自带的质量检查负责。短篇质量检查见 story-short-analyze 各阶段的完成标志。

---

## Phase 3:结构迁移

将 `拆文库/{导入书名}/` 的分析结果迁移为可被写作 skill 消费的项目结构。

### 分流路由

按 Phase 1 判定的篇幅类型分流,两条路径产出的工程结构完全不同:

| 篇幅 | 迁移路径 | 映射规则 | 续写接手 |
|------|---------|---------|---------|
| 长篇 | **3-L:长篇结构迁移** | [references/structure-mapping-long.md](references/structure-mapping-long.md) | story-long-write 日更循环 |
| 短篇 | **3-S:短篇结构迁移** | [references/structure-mapping-short.md](references/structure-mapping-short.md) | story-short-write Phase 3 逐场景写作 |

---

## Phase 3-L:长篇结构迁移

将 `拆文库/{导入书名}/` 的分析结果迁移为 `{导入书名}/` 长篇项目结构。迁移规则详见 [references/structure-mapping-long.md](references/structure-mapping-long.md)。

### 迁移步骤

#### Step 1:创建项目骨架

```
{导入书名}/
├── 设定/
│   ├── 世界观/
│   ├── 角色/
│   └── 势力/
├── 大纲/
├── 正文/
├── 追踪/
│   └── 逐章记录/
├── 对标/                       # 可选;仅在显式绑定外部对标时创建子目录
└── 参考资料/
```

#### Step 2:正文标准化

将原文迁移到 `正文/`,统一命名格式:`第XXX章_章名.md`。

- 识别章节分隔符(第X章、Chapter X 等)
- 提取章节标题
- 补零对齐编号(第1章 → 第001章)
- 保留原文内容不变

#### Step 3:角色文件迁移

将 `拆文库/{导入书名}/角色/{角色名}.md` 迁移到 `设定/角色/{角色名}.md`。

迁移时按 `references/structure-mapping-long.md` 的「角色文件迁移模板」补齐 story-long-write 角色模板字段。

角色分级(沿用 story-long-analyze 标准):

| 等级 | 标准 | 迁移策略 |
|------|------|---------|
| 主角 | 出现章节 ≥50% + 推动主线 + 完整成长轨迹 | 完整迁移 |
| 反派 | 与主角对立 + 推动核心冲突 + 明确动机 | 完整迁移 |
| 核心配角 | 出现章节 ≥20% 或推动重要支线 | 完整迁移 |
| 功能角色 | 出现章节 <20% + 作用有限 | 简化迁移 |

#### Step 4:关系文件迁移

将 `拆文库/{导入书名}/角色/角色关系.md` 转换为 `设定/关系.md`,按 [structure-mapping-long.md](references/structure-mapping-long.md)「关系文件转换规则」的目标格式模板输出。

#### Step 5:同步世界观设定

当前拆文契约已按主题输出 `拆文库/{导入书名}/设定/世界观/*.md` 与 `设定/势力/*.md`。导入时原样同步到项目;`世界观/` 必须包含 `背景设定.md`。`力量体系.md` 小于 200 字并已并入 `背景设定.md` 时可省略;否则缺失当前必需产物时停止并提示重跑 story-long-analyze Stage 4。不再现场拆分扁平文件。

#### Step 6:大纲生成

**大纲.md**(卷级结构):从 `剧情/故事线.md`、`剧情/*.md` 和 `快速预览.md` 反推。**卷划分采用用户确认制**,规则见 [structure-mapping-long.md](references/structure-mapping-long.md)「大纲反推规则」:

- **原文有明确卷界**(存在「第一卷」「卷一」等卷级标题)→ 按原文卷界直接划分,无需询问。
- **原文无明确卷界** → **不机械按「每卷 20-40 章」硬切**。根据故事线/场景切换/大型时间跳跃检测候选卷边界,向用户展示候选划分方案,**等待用户确认后**才写定卷纲;用户确认前 `大纲/大纲.md` 只记录候选方案。

```markdown
# 全书大纲

## 卷级大纲

### 第一卷:{卷名}(约 {X} 万字,{Y} 章)
- 功能:{从剧情分析推断}
- 核心事件:{一句话}
- 起始状态 → 结束状态:{从角色弧线推断}
```

**卷纲**:卷划分确认后,从剧情文件聚合生成 `大纲/卷纲_第X卷.md`,按 [structure-mapping-long.md](references/structure-mapping-long.md)「卷纲反推」模板格式。

**细纲**:从章节摘要反推生成 `大纲/细纲_第XXX章.md`:

每章先通过 story-long-write 的 Wordcount Core 运行 `wordcount measure`,将 JSON 的 `actual` 作为已写章节的历史长度快照。这里记录的是原文在 `visible_chars_v1` 下的实际长度,不是让模型重新决定创作目标。依次探测 `python3`、`python`、`py -3`;找不到 Python 3 或 CLI 时返回 `TOOL_UNAVAILABLE` 并停止导入,不得用模型估算或静默跳过。

```bash
{PYTHON} {story-long-write skill 根}/scripts/storyctl.py wordcount measure \
  --file "{原文章节文件}" \
  --chapter {N}
```

```markdown
## 细纲(第 N 章)

### 第 N 章:{章名}
- 核心事件:{从摘要中提取}
- 字数目标:{storyctl 返回的 actual} 字
- 字数口径:visible_chars_v1
- 目标情绪:{从章节基调/情绪曲线提取;未知写 [待补充]}
- 章首钩子:[待补充]
- 爽点:{从情节点推断;无明确证据写 [待补充]}

#### 内容概括(五段式)
- 起因:{从情节点归纳;未知写 [待补充]}
- 发展:{从情节点归纳;未知写 [待补充]}
- 转折:{从情节点归纳;未知写 [待补充]}
- 高潮:{从情节点归纳;未知写 [待补充]}
- 结尾:{原文最后落在什么动作/画面/台词上;未知写 [待补充]}

#### 情节安排(多线)
- 主线推进:{从剧情单元索引/摘要反推}
- 辅线推进:{无证据写“无”或 [待补充]}
- 事件线 / 任务线:{外部事件链}
- 感情线 / 关系线:{有证据才写;否则“无显性”或 [待补充]}
- 逻辑线:原因 → 行动 → 结果 → 后果/新问题

#### 人物关系和出场顺序
- 出场顺序:{摘要中角色/势力/关键物件出现顺序}
- 人物关系变化:{本章前 → 本章后;未知写 [待补充]}
- 视角/信息差:{谁知道什么;读者知道什么;主角误判什么;未知写 [待补充]}

#### 情节细化
- 情节点序列(逐行填下表;从摘要情节点反推):

| # | 情节点(谁做了什么) | 功能标签 | 执行边界 |
|---|---|---|---|
| 1 | {} | {功能不明写 [待补充]} | {从原文确认本点没有释放什么;未知写 [待补充]} |
- 行动成本(可无)/收益归属:{有证据才写;行动成本可无、不硬造;未知写 [待补充]}

#### 结尾设定和钩子
- 结尾设定:{原文收束落在什么动作或画面;未解决问题;下一章推动力;未知写 [待补充]}
- 章尾钩子:[待补充]
```

> 钩子、人物关系变化、辅线/感情线、行动成本/收益归属等无法由原文摘要稳定判断的字段统一标 `[待补充]`;story-import 只反推有证据的蓝图,不为补齐字段编造关系或副线。

#### Step 7:追踪文件生成

导入项目必须通过本 skill 自带的 `scripts/tracking_commit.py init` 一次性生成追踪状态,禁止模型分别写最终文件。完整字段与命令见 [references/tracking-transaction.md](references/tracking-transaction.md)。语义准备顺序如下:

1. **导入截止章**:把最后完整章 N 写入初始化事务的 `last_chapter`。工具在 meta 记录 `imported_through_chapter=N`;导入旧章没有日更事务,不得为第 1..N 章伪造逐章增量,也不额外生成一份重复当前状态的叙事基线。
2. **核心角色当前快照**:从拆书产物反推主角、反派、核心配角的截至 N 章状态,按角色写入初始化 JSON 的 `character_snapshots`。输出由工具生成到 `追踪/角色状态/{角色名}.md`;算法见 [references/character-state-reverse.md](references/character-state-reverse.md)。
3. **伏笔当前行**:从有正文证据的铺垫/回收事件生成 `foreshadow`。每个 ID 只保留当前状态一行;尚未实际埋设的未来设计留在大纲,不写 `伏笔.md`。
4. **事实与读者认知**:把关键事件生成到 `timeline_events`。同一事件同时写客观事实、读者截至 N 章已知内容和实际揭示状态;未来计划揭示章不得伪装成已发生事实。
5. **续写状态卡输入**:准备当前位置、长期约束、活跃核心角色、近三章速记、下一章承诺和连贯性风险。`上下文.md` 由工具生成固定 7 栏,不把文风、文件索引、普通待办或质检计数塞进续写状态卡。
6. **执行初始化**:按当前平台探测 Python 3(`python3` → `python` → `py -3`),执行:

   > 项目 `追踪/` 里已有不属于当前协议的早期文件时不必手工清理:`init` 会先把它们按原样整体移入 `追踪/_旧追踪存档/`,再在原地建当前协议。旧内容保留供作者查阅,不参与解析,当前状态完全由本次导入输入决定;校验失败的 `init` 不移动任何文件。

   ```text
   {PYTHON} {story-import skill 根}/scripts/tracking_commit.py init --project {项目根} --input {初始化事务.json}
   {PYTHON} {story-import skill 根}/scripts/tracking_commit.py check --project {项目根}
   ```

以 demo《让你管账号,你高燃混剪炸全网》导入至第 10 章为例:续写状态卡要写清江晨的手机原版《诸君,且听龙吟》被专业团队高清重拍,但高层看片后认为新版“缺了灵魂”,最终继续采用原版;江晨快照应体现其军宣创作价值已获周薄森、张耀祖确认;读者时间线只写读者已经看到的看片会结论,钟嘉嘉“只猜对了一半”背后的培养安排若尚未揭示,只能出现在作者真相,不能泄露到读者视图。

初始化成功后应得到:

```text
追踪/
├── _tracking-state.json
├── 上下文.md
├── 逐章记录/                 # 导入旧章不补造文件,续写从第 N+1 章开始
├── 角色状态/{角色名}.md
├── 伏笔.md
├── 时间线/
│   ├── 作者真相.md
│   └── 读者已知.md
```

半成品最后一章为残稿时,`last_chapter`、角色快照和其他当前语义检查点一律截至最后完整章;残稿处理策略写入连贯性风险,不把未完成动作登记成既成事实。

#### Step 8:题材定位生成

从拆文报告中提取核心发现,生成 `设定/题材定位.md`(按 [structure-mapping-long.md](references/structure-mapping-long.md)「题材定位生成」模板格式)。

`设定/题材定位.md` 的本书题材、核心梗、情绪与节奏摘要来自 `拆文库/{导入书名}/`,但这些字段不是对标登记。只有 Phase 1 已明确绑定外部对标时,才追加「对标书清单 + 主对标书」段;主对标书最多 1 本,副对标 / 参考对标不限制数量。未绑定时省略整个对标登记段,不得用 `{导入书名}` 补位。该段格式见上述「题材定位生成」模板的「对标书清单」。

后续如需快速概览,可另写「对标分析(派生概要)」表;该表不是权威 registry,不得替代 `主对标书` 与完整 `对标书列表`。所有登记项必须能回溯到对应 `拆文库/{对标书名}/`,不得引用本书根 `设定/`。

#### Step 9:对标结构化资产同步

本步只处理 Phase 1 显式绑定的外部参考作品。把 `拆文库/{对标书名}/` 的结构化分析资产同步到项目引用视图 `{项目}/对标/{对标书名}/`,供 story-long-write 优先读取。没有绑定外部对标时跳过本步,不创建空目录;严禁使用 `拆文库/{导入书名}/` 或项目 `设定/` 作为复制源。源路径→目标路径的完整同步映射见 [structure-mapping-long.md](references/structure-mapping-long.md)「对标引用视图同步规则」。

**缺失处理**:

- 已选外部对标缺 `剧情/节奏.md` 或 `剧情/情绪模块.md` → 不登记、不生成半套对标视图;报告 `module_or_rhythm_required_missing` 并提示对 `{对标书名}` 重跑 `/story-long-analyze` Stage 3+。本书核心工程迁移不因此回滚。
- 其它结构化子目录缺失 → 按既有导入缺失项提示,不阻塞项目创建

#### Step 10:文风同步

外部对标已通过 Step 9 校验时,把 `拆文库/{对标书名}/文风.md` 复制到 `{项目}/对标/{对标书名}/文风.md`。纯复制,不重新生成;未绑定外部对标时跳过。

**缺失处理**:

- 拆文库没有文风文件(analyze 未跑 Stage 6)→ 导入报告提示用户重跑 `/story-long-analyze` 后再同步;日更前文风缺失会被 fail-fast 拦截
- 项目对标已有旧文风文件 → 覆盖(最新拆文产物优先),在导入报告告知

---

## Phase 3-S:短篇结构迁移

将 `拆文库/{导入书名}/` 的短篇拆文产物迁移为 `{短篇标题}/` 短篇工程结构,供 story-short-write Phase 3 逐场景写作无缝接手。迁移规则详见 [references/structure-mapping-short.md](references/structure-mapping-short.md)。

> **短篇工程与长篇完全不同**:短篇正文是单文件 `正文.md`(不切章),**不产** `追踪/`、`大纲/`、`正文/` 等长篇目录。迁移时严禁误建这些长篇专属目录。

### 短篇目标工程结构

```
{短篇标题}/
├── 设定.md              ← 含核心框架 + 本书续写基线
├── 小节大纲.md          ← 按段-小节结构反推
├── 正文.md              ← 单文件全文正文
└── 对标/{对标书名}/     ← 可选:仅外部对标引用视图
    ├── 拆文报告.md
    ├── 情节节点.md
    └── 写作手法.md
```

### 迁移步骤

#### Step 1:正文迁移

将 `拆文库/{导入书名}/原文/` 的全文迁移为单文件 `{标题}/正文.md`,按 [format-and-structure.md](references/format-and-structure.md) 规范化格式(小节标记 `###1.`、段间仅单换行、对话引号按项目/平台约定统一)。**原文已是成稿,不重写内容,只规范格式。**

#### Step 2:设定生成

从 `拆文报告.md`、`写作手法.md` 反推 `{标题}/设定.md`,含两个区块:

- **核心框架**:对齐 story-short-write 核心框架模板(基本信息、一句话梗概、核心反转、情绪设计、人设速写)。
- **本书续写基线**:把已写内容的故事结构、情绪节奏、核心反转机制、既有写作手法写入续写基线区;这是本书内部上下文,不是对标摘要。

#### Step 3:小节大纲生成

从 `情节节点.md` 的功能分段反推 `{标题}/小节大纲.md`,按开头段/铺垫段/升级段/反转段/结尾段映射;短篇只做轻量蓝图:每节写 `结构段/五段功能`、主事件、3-5 个子事件、目标情绪、人物/关系变化、因果/逻辑链、结尾承接/小钩子。钩子或关系无法判断时标 `[待补充]`,不套用长篇完整章节蓝图。

#### Step 4:外部对标引用视图(可选)

仅当 Phase 1 已显式绑定外部 `{对标书名}` 时,才把 `拆文库/{对标书名}/` 同步为 `{标题}/对标/{对标书名}/`;没有绑定则跳过。不得把 `拆文库/{导入书名}/` 整体复制进 `对标/`。

---

## Phase 4:项目激活

### Step 1:质量检查

按篇幅对照对应的质量检查清单:

- **长篇**:完整导入质量清单见 [references/structure-mapping-long.md](references/structure-mapping-long.md) 末尾(含正文文件数对照、核心角色独立快照、作者/读者时间线隔离、`tracking_commit.py check` 通过、卷划分已经用户确认等)。
- **短篇**:质量清单见 [references/structure-mapping-short.md](references/structure-mapping-short.md) 末尾的质量检查清单(含 `正文.md` 单文件存在且格式合规、`设定.md` 含核心框架+本书续写基线、未误建长篇专属目录等)。

### Step 2:缺失项提示

输出导入结果摘要和待补充项,按篇幅分支。

**长篇导入完成报告**:

```
=== 导入完成报告(长篇)===
书名:{导入书名}
源文件:{X} 章,{Y} 万字
项目目录:{路径}

## 已生成文件
- 正文:{N} 章
- 角色文件:{M} 个
- 大纲:大纲.md + {V} 个卷纲 + {N} 个细纲
- 追踪:唯一结构化 state + 核心角色独立派生快照 + 伏笔当前视图 + 时间线双视图 + 空逐章记录目录 + 固定 7 栏上下文
- 设定:{世界观文件数} 个
- 外部对标:{未绑定 / 已从 `拆文库/{对标书名}/` 同步到 `对标/{对标书名}/` / 绑定失败及修复动作}

## 待补充项
- [ ] 细纲中的章首/章尾钩子需要补充
- [ ] 题材定位的核心梗三分法需要确认
- [ ] 伏笔追踪中的伏笔已复核
- [ ] `追踪/时间线/读者已知.md` 未泄露 `作者真相.md` 中尚未揭示的事实
- [ ] 卷划分已确认(原文无明确卷界时)
- [ ] `拆文库/{导入书名}/` 未被复制到项目 `对标/`,本书未登记为自身对标
- [ ] 若绑定外部对标,`设定/题材定位.md` 的 `主对标书` 与 `对标书列表` 只包含独立 `{对标书名}`,且同步来源与目录名一致

## 下一步操作
- 运行 `/story-review lean` 审查导入结果
- 运行 `/story-long-write` + "日更" 开始续写
```

**短篇导入完成报告**:

```
=== 导入完成报告(短篇)===
标题:{短篇标题}
源文件:{Y} 字
项目目录:{路径}

## 已生成文件
- 正文.md(单文件,{Y} 字)
- 设定.md(核心框架 + 本书续写基线)
- 小节大纲.md({N} 个小节)
- 外部对标:{未绑定 / `对标/{对标书名}/` 已同步 / 绑定失败及修复动作}

## 待补充项
- [ ] 所有 [待补充] 标记的文件已复核
- [ ] 小节大纲的章首/章尾钩子需要补充
- [ ] 核心反转的铺垫线索已确认

## 下一步操作
- 运行 `/story-short-write` Phase 3 开始续写
```

### Step 3:项目激活

- 设置 `.active-book` 指向导入的书名/标题目录
- 确认项目可以被对应写作 skill 识别(长篇 → story-long-write,短篇 → story-short-write)
- 可选验证:如果当前运行时的 canonical 目录已部署 story-explorer agent,可 spawn 交叉验证迁移数据完整性;Antigravity 检查 `.agents/agents/story-explorer/agent.md` 并用 `invoke_subagent` + `TypeName: "story-explorer"`。Prompt:`项目目录:{dir}\n查询类型:progress\n查询参数:导入验证`

> setup 环境检测已在 Phase 1「环境检测前置」完成,此处不再重复检测。

---

## 大型作品处理(>200 章)

> 本节仅适用于长篇导入。短篇为单文件全量迁移,无增量导入需求。

超过 200 章的作品,**拆解可以分批,追踪初始化必须一次覆盖全部已写章节**:

1. **拆解分批**:首期只深拆前 50 章 + 全书概要,后续按需补拆更多章节到 `拆文库/`。
2. **追踪一次到位**:初始化事务的 `last_chapter` 写**最后一个已写完的章号 N**,不是首期拆解的 50。`imported_through_chapter` 由 `init` 一次写定、之后不再推进,逐章事务只接受 N+1 起的章号;第 1..N 章不伪造逐章记录,续写从 N+1 开始。若 init 时误写成 50,第 51..N 章仍可逐章 `append` 补上(一章一份事务,章号必须连续),只是要为已写好的旧章逐章构造事务;不要删 `追踪/` 重来——`_旧追踪存档/` 也在里面。
3. **上下文摘要**:未深拆的章节生成简化摘要(200 字/章),供反推当前状态用。

---

## 参考资料索引

按阶段加载,不一次全部加载。

本 skill 自带的 reference 文件全部位于 `references/`,按场景加载。涉及别的 skill 的方法论/模板时,story-import 不直接加载文件,而是运行对应 `/命令` 由该 skill 自行加载。

### Phase 1:确认导入源

| 场景 | 加载文件 |
|------|---------|
| 篇幅分流判定 | `references/length-routing.md` |
| 章节格式识别 | 由 story-long-analyze 拆解管道(运行 `/story-long-analyze`)的阶段 1 负责 |

### Phase 2:深度分析

| 场景 | 加载文件 / 相关 skill |
|------|---------|
| 长篇深度分析(方法论、质量检查、输出模板均自带) | 运行 `/story-long-analyze` 调用长篇拆解管道 |
| 短篇深度分析(方法论、质量检查、输出模板均自带) | 运行 `/story-short-analyze` 调用短篇拆解管道 |

### Phase 3:结构迁移

| 场景 | 加载文件 |
|------|---------|
| 长篇迁移映射规则 | `references/structure-mapping-long.md` |
| 短篇迁移映射规则 | `references/structure-mapping-short.md` |
| 角色状态反推规则(长篇) | `references/character-state-reverse.md` |
| 角色状态规则(character-state-reverse.md 依赖) | `references/state-tracking.md` |
| 短篇正文格式规范 | `references/format-and-structure.md` |

> 长篇细纲模板格式参见 story-long-write(Phase 3 细纲部分);短篇核心框架模板参见 story-short-write(核心框架部分)。这两项为纯文本指引,story-import 不加载对应 skill 的文件。

### Phase 4:项目激活

| 场景 | 说明 |
|------|---------|
| 长篇项目结构规范 | 参见 story-long-write(Phase 4 项目文件结构) |
| 短篇项目结构规范 | 参见 story-short-write(Phase 3 项目结构) |
| 环境部署 | 部署模板由 `/story-setup` 提供,story-import 不负责部署 |

---

## 流程衔接

**流水线:** 长篇 / 短篇
**位置:** 导入(在开书之前)

| 时机 | 跳转到 | 命令 |
|---|---|---|
| 导入完想继续写(长篇) | story-long-write | `/story-long-write` + "日更" |
| 导入完想继续写(短篇) | story-short-write | `/story-short-write` |
| 导入完想审查质量 | story-review | `/story-review` |
| 想深入分析对标(长篇) | story-long-analyze | `/story-long-analyze` |
| 想深入分析对标(短篇) | story-short-analyze | `/story-short-analyze` |
| 从零开新书(长篇) | story-long-write | `/story-long-write` + "开书" |
| 从零开新书(短篇) | story-short-write | `/story-short-write` |
| 项目未部署环境 | story-setup | `/story-setup` |

---

## 语言

- 跟随用户的语言回复,用户用什么语言就用什么语言回复
- 中文回复遵循《中文文案排版指北》
story-import · Agent Skills en tendance | Mengbi