Răsfoiți Sursa

feat(v7): M3 P0——git 健康检查激进自动修 + 可恢复安全网

登记 M3 规划(prd/design/implement,brainstorm 收敛 D1 范围=全脚本面+AI DTO 缝、
D2 git=激进自动修+可恢复安全网)+ P0 实现:

- git.js 扩展:health/status/isMerging/hasStagedChanges/stash/mergeAbort/createBackupRef
- checkGitHealth(spec §10 第 0 步前,D2):陈旧锁→自动删;网盘副本→归档(不删);
  半提交→stash(可恢复);合并冲突→备份 MERGE_HEAD 后 abort;.git 损坏→例外只中文指引
- 每次自动修写 工作区/.救援/修复日志.md(改了什么+怎么撤);作者永不见 git 英文报错
- git 异常样本库 5 例(mkdtemp+git init 造坏仓库),断言修复/归档/指引 + 安全网可恢复 + 零英文堆栈
lingfengQAQ 1 zi în urmă
părinte
comite
e1c483d2bb

+ 1 - 0
.trellis/tasks/06-27-m3-state-machine/check.jsonl

@@ -0,0 +1 @@
+{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}

+ 100 - 0
.trellis/tasks/06-27-m3-state-machine/design.md

@@ -0,0 +1,100 @@
+# 技术设计:M3 状态机单入口 + git 隐身全套
+
+## 1. 范围与分层
+
+M3 是**编排层**:单入口状态机先跑 git 健康检查,再按 §10 序 0-6 命中即停判定「当前该做什么」。状态机只路由、不判业务、不调 AI——AI 态备好 DTO 返回「需 AI」交 M4(架构原则 §1.5)。
+
+执行体分裂:
+- **纯脚本(M3 全做)**:git 健康检查、路由器、7 态检测、外环流程(影响分析/回到第N章/吃书/手改补登/分支 git 部分)。
+- **AI 态动作(M4)**:建书问答、修复提议、卷复盘对谈、细纲拟提案、推演、圆设定 —— M3 出 DTO + `needsAI`。
+- **M3+/后续**:体检文体指纹提取(M3 体检只做条目活跃率/时间线孤儿脚本项)。
+
+模块落点(填 M0 占位 `v7/src/state-machine/`):
+```
+v7/src/state-machine/
+├── index.js            # determineNextState(ctx):git健康 → 序0-6 路由
+├── git-health.js       # 5 类异常检测 + 激进自动修复(D2,可恢复安全网)
+├── detectors.js        # 序0-6 各态检测(命中即停)
+├── flows/
+│   ├── impact.js       # 影响分析(grep 正文+履历+时间线)
+│   ├── goto-chapter.js # 回到第N章(git 回滚包装 + 影响范围)
+│   └── retcon.js       # 吃书(retcon commit + 设定/条目同步)
+└── dto.js              # AI 态上下文 DTO 组装
+```
+复用 M1(grep-story/reports/cache)、M2(finalize/git.js 扩展、assembleBookStatus)。
+
+## 2. 单入口契约
+
+```js
+// determineNextState(ctx) → 作者的「继续」按钮背后
+// @param {{repoPath, cache}} ctx
+// @returns {Promise<{
+//   ok, gitHealth: {fixed:[], guidance:[], rescued:[]},
+//   序: 0..6, state: string, needsAI: boolean, dto?: object,
+//   message: string  // 中文,告诉作者/宿主当前该做什么
+// }>}
+```
+CLI 单入口:`v7/src/commands/next.js`(`webnovel-writer next`,run 契约)→ 调 determineNextState,打印 message + 必要数据。SessionStart hook 注入留 M4/M5(无 hook 宿主走这个 CLI 等价路径)。
+
+## 3. git 健康检查(D2:激进自动修 + 可恢复安全网)
+
+`checkGitHealth(ctx) → {ok, fixed:[], guidance:[], rescued:[]}`。在序 0 前跑。**改 git 状态前必先快照**;返回中文小结,作者永不见 git 英文报错(不变量 8)。
+
+| 异常 | 检测 | 处理(B 激进) | 安全网 |
+|------|------|--------------|--------|
+| 陈旧锁文件 `.git/index.lock` | 文件存在且陈旧(mtime 超阈值,无活跃 git 进程迹象) | 自动删 | 删前记 rescued 日志 |
+| 网盘冲突副本 `xxx (1).md`/`xxx 的冲突副本` | 文件名模式匹配 | 自动**归档**到 `工作区/.救援/网盘副本/` | 移动非删除,可找回 |
+| 半提交(中断的定稿,工作树脏) | `git status --porcelain` 非空且非正常流程 | 自动 `git stash`(push 到备份) | stash 可 pop 恢复 |
+| 合并冲突 | `git status` 含 `UU`/MERGING | 先 `git stash`/备份 ref,再 `git merge --abort` | 备份 ref 可回 |
+| `.git` 损坏 | `git fsck`/`git status` 报错 | **例外:不自动修**,检测 + 中文指引(建议备份后 `git fsck`) | 不动用户仓库 |
+
+实现:git 操作复用并扩展 M2 `src/finalize/git.js`(加 `status`/`stash`/`mergeAbort`/`fsck`/`config`)。每次自动修在 `工作区/.救援/修复日志.md` 追加一行(修了什么、时间、怎么撤)。
+
+## 4. 状态机路由(序 0-6,命中即停)
+
+`detectors.js` 导出按序判定函数,determineNextState 依序调用,首个命中返回。
+
+| 序 | 检测(脚本) | needsAI | dto / 动作 |
+|----|-------------|---------|-----------|
+| 0 修复确认 | 扫源文件,任一 `parseFrontMatter`/`parseMarkdownTable` 失败;全角冒号/逗号在结构位 = 确定性错误(预修复) | 是(AI 提议保意图修复) | dto: {文件, 行, 上下文, 确定性预修复结果} |
+| 1 建书引导 | 无 `book.yaml` / `books.jsonl` 当前书不存在 | 是(问答) | 脚本可先建空骨架;dto: {缺什么} |
+| 2 手改补登 | `git status --porcelain -- 定稿 大纲` 非空(未登记手改) | 否 | 动作:提议 `fix(...)` commit(复用 M2 git) |
+| 3 断点续跑 | `工作区/` 有未完成流程(草稿/审稿/待定稿批次存在但未定稿) | 否 | 动作:返回中断阶段,引导续跑 |
+| 4 卷复盘 | 最大已定稿章 = 卷末(卷规模整除 / 卷纲耗尽) | 是(对谈) | 脚本清账(本卷开/收/悬了太久);dto 给 AI |
+| 5 体检 | 章号到体检周期(book.yaml,默认每 50 章) | 部分 | 脚本:条目活跃率(M1)/时间线孤儿;指纹推 M3+ |
+| 6 起草细纲 | 其余 | 是(拟提案) | 脚本:assembleBookStatus(M2);dto 给 AI 拟提案 |
+
+序 0 的「确定性错误预修复」:全角 `:,` 出现在 YAML 键值结构位 → 脚本可确定性替换为半角后只报不问(spec §10);其余解析失败 → needsAI。
+
+## 5. 外环脚本流程(§9,纯脚本)
+
+- **影响分析** `analyzeImpact(ctx, {关键词|实体})`:grep-story 正文 + 扫条目履历 + 时间线,列引用章号;按「已发布章号」(book.yaml `已发布到章`,默认 0=全未发布)分**已发布/未发布**两清单。返回结构化,不改文件。
+- **回到第 N 章** `gotoChapter(ctx, {chapterNum, confirm})`:`git log --grep "ch(N):"` 定位 commit → 先展示影响范围(该章之后的章/commit)→ `confirm=true` 才 `git reset`(**先建备份 ref** `救援/回退前-<时间>`)。不变量 8:作者只说「回到第 N 章」,不碰 git。
+- **吃书 retcon** `retcon(ctx, {chapterNum, 原因, 设定变更, 条目变更})`:改定稿 + `retcon(N): 原因` commit + 设定/条目同步(复用 M2 Writer + finalize 编排范式)。圆设定(AI)留 M4。
+
+## 6. AI 态 DTO 缝
+
+`dto.js`:为序 0/1/4/6 + 分支试写组装 AI 所需上下文(不调 AI,只备料)。形如 `{state, 上下文片段, 作者可选项, 期望产物}`。M4 吃 DTO 出结构化产物,回流由 M3 落盘(M3 提供落盘函数,M4 不碰文件)。M3 测试断言 DTO 字段齐全 + `needsAI=true`。
+
+## 7. 测试策略(镜像 src,TDD)
+
+**git 异常样本库**(核心出口):用 `mkdtemp + git init` 构造每种异常的仓库样本,断言 checkGitHealth 自动修复/归档/指引正确 + 安全网可恢复 + 输出零英文堆栈:
+- 造陈旧 `index.lock` → 断言自动删 + rescued 记录
+- 造 `章节 (1).md` → 断言归档到 `工作区/.救援/`
+- 造脏工作树 → 断言 stash + 可 pop
+- 造 MERGING 状态(制造冲突)→ 断言备份 + abort
+- 造 `.git` 损坏(截断 HEAD)→ 断言只指引、不乱动、message 中文
+
+**状态机路由**:`test/state-machine/detectors` 构造命中各序 0-6 的仓库,断言 determineNextState 返回正确 序/state/needsAI/dto。命中即停顺序也要测(如同时有手改 + 卷末 → 先报序 2)。
+
+**外环流程**:影响分析(grep 命中 + 已发布/未发布分组)、回到第N章(reset 到目标 + 备份 ref 存在)、吃书(retcon commit message + 设定同步)。
+
+**不变量回归**:删 `.cache` 重建一致、定稿原子不破坏。
+
+## 8. 边界与非目标
+
+- ❌ AI 态真实动作(建书问答/修复提议/卷复盘对谈/细纲提案/推演/圆设定)→ M4
+- ❌ 体检文体指纹提取 → M3+/M4
+- ❌ 自动模式连写/批次/污染传播 → M6
+- ❌ SessionStart hook 真实注入 → M4/M5(M3 给 CLI 等价入口)
+- ❌ `.git` 损坏的自动修复(D2 例外,只指引)

+ 1 - 0
.trellis/tasks/06-27-m3-state-machine/implement.jsonl

@@ -0,0 +1 @@
+{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}

+ 87 - 0
.trellis/tasks/06-27-m3-state-machine/implement.md

@@ -0,0 +1,87 @@
+# 执行计划:M3 状态机单入口 + git 隐身全套
+
+> 前置:已读 spec §9(外环/例外)/§10(启动序列与状态机)、不变量 8、v7-implementation-plan §1.5、M3 prd/design。
+> 落点 `v7/src/state-machine/`、`v7/src/commands/next.js`、`v7/test/`。复用 M1 读接口、M2 finalize/git.js。
+> 本机:`cd v7 && PYTHONUTF8=1 node --test`;零依赖延续;命令走 `run(args,options,ctx)` 契约。
+> 工作方式:不开子代理;TDD(先红后绿);诚实分期 commit。
+
+## 分期(D1:单任务分期,git健康 → 路由+检测 → 外环流程 → DTO 缝)
+
+```
+P0 git.js 扩展 + git 健康检查(5 异常 + 安全网)+ git 异常样本库
+   ↓
+P1 状态机路由器 determineNextState + 序0-6 检测 + next CLI
+   ↓
+P2 外环脚本流程(影响分析 / 回到第N章 / 吃书 retcon)
+   ↓
+P3 AI 态 DTO 缝 + 落盘函数
+   ↓
+P4 全量 AC 复核 + 推送验证 CI(git 异常在 Windows 跑)
+```
+
+## P0 git 健康检查(D2:激进自动修 + 可恢复安全网)
+
+- [ ] P0.1 扩展 `src/finalize/git.js`:加 `status()`、`stash(msg)`、`mergeAbort()`、`fsck()`、`config(k,v)`、`createBackupRef(name)`(错误转中文)
+- [ ] P0.2 `src/state-machine/git-health.js`:`checkGitHealth(ctx) → {ok, fixed, guidance, rescued}`
+  - 陈旧 `index.lock` → 自动删(记 rescued)
+  - 网盘副本 `xxx (1).md`/`的冲突副本` → 归档到 `工作区/.救援/网盘副本/`(移动非删)
+  - 半提交脏工作树 → `git stash`(可恢复)
+  - 合并冲突 → 备份 ref + `merge --abort`
+  - `.git` 损坏 → **例外**只检测 + 中文指引
+  - 每次自动修在 `工作区/.救援/修复日志.md` 追加「修了什么 + 怎么撤」
+- [ ] P0.3 `test/state-machine/git-health.test.js`:git 异常样本库逐个(mkdtemp+git init 造各异常),断言修复/归档/指引 + 安全网可恢复 + **零英文堆栈**
+
+**验证 P0**:`node --test test/state-machine/git-health.test.js` 全绿
+**提交 P0**:`feat(v7): M3 P0——git 健康检查激进自动修 + 可恢复安全网`
+
+## P1 状态机路由器 + 7 态检测
+
+- [ ] P1.1 `src/state-machine/detectors.js`:序 0-6 各检测函数(解析失败/无书/手改/工作区未完成/卷末/体检周期/其余)
+- [ ] P1.2 `src/state-machine/index.js`:`determineNextState(ctx)` —— 先 checkGitHealth,再序 0-6 命中即停,返回 `{序, state, needsAI, dto?, message, gitHealth}`
+- [ ] P1.3 序 0 确定性预修复:全角 `:,` 在 YAML 结构位 → 半角替换后只报不问;其余解析失败 needsAI
+- [ ] P1.4 `src/commands/next.js`:`run` 契约调 determineNextState,打印中文 message
+- [ ] P1.5 `test/state-machine/router.test.js`:构造命中各序的仓库,断言判定正确 + 命中即停顺序
+
+**验证 P1**:`node --test test/state-machine/` 全绿;`webnovel-writer next` 冒烟
+**提交 P1**:`feat(v7): M3 P1——状态机单入口路由 + 序0-6 检测 + next CLI`
+
+## P2 外环脚本流程
+
+- [ ] P2.1 `src/state-machine/flows/impact.js`:`analyzeImpact(ctx,{关键词})` grep 正文+履历+时间线,按 `已发布到章` 分两清单
+- [ ] P2.2 `src/state-machine/flows/goto-chapter.js`:`gotoChapter(ctx,{chapterNum,confirm})` 定位 ch(N) commit → 展示影响 → confirm 才 reset(先建备份 ref)
+- [ ] P2.3 `src/state-machine/flows/retcon.js`:`retcon(ctx,{chapterNum,原因,...})` retcon commit + 设定/条目同步(复用 M2 Writer/finalize 范式)
+- [ ] P2.4 CLI:`impact`/`goto-chapter`/`retcon` 命令(run 契约)
+- [ ] P2.5 `test/state-machine/flows/`:影响分析分组、回到第N章(reset+备份 ref)、吃书(retcon message+同步)
+
+**验证 P2**:`node --test test/state-machine/flows/` 全绿
+**提交 P2**:`feat(v7): M3 P2——外环脚本流程(影响分析/回到第N章/吃书)`
+
+## P3 AI 态 DTO 缝
+
+- [ ] P3.1 `src/state-machine/dto.js`:为序 0/1/4/6 + 分支试写组装 AI 上下文 DTO(不调 AI)
+- [ ] P3.2 落盘函数:AI 产物回流由 M3 落盘(M4 不碰文件)的接口占位 + 契约
+- [ ] P3.3 `test/state-machine/dto.test.js`:各 AI 态 DTO 字段齐全 + needsAI=true
+
+**验证 P3**:`node --test test/state-machine/` 全绿
+**提交 P3**:`feat(v7): M3 P3——AI 态 DTO 缝 + 落盘契约`
+
+## P4 AC 复核 + 推送
+
+- [ ] P4.1 全量 `node --test` 绿;过 prd Acceptance(7 态 e2e / git 异常样本库零英文堆栈 / 外环流程)
+- [ ] P4.2 不变量回归(删缓存重建、定稿原子)
+- [ ] P4.3 推送验证 CI 双平台(git 异常处理在 Windows 跑)
+
+**提交 P4**:`feat(v7): M3 P4——AC 复核 + CI 双平台`
+
+## 回滚点
+
+- 各 P 独立,未提交前 `git restore v7/` 对应子目录
+- P0 git.js 扩展单点;P2 回到第N章/吃书涉及 git reset,务必先备份 ref
+
+## 出口判据(对齐 prd Acceptance)
+
+- [ ] 7 个态各有端到端测试(命中各序判定正确 + 命中即停)
+- [ ] git 异常样本库逐个演练(半提交/冲突/锁/损坏/网盘副本),零英文堆栈,安全网可恢复
+- [ ] 影响分析/回到第N章/吃书 纯脚本流程有测试
+- [ ] 不破坏 M1/M2 不变量(删缓存重建、定稿原子)
+- [ ] CI 双平台绿(git 异常处理在 Windows 验证)

+ 66 - 0
.trellis/tasks/06-27-m3-state-machine/prd.md

@@ -0,0 +1,66 @@
+# M3 状态机单入口 + git 隐身全套
+
+## Goal
+
+给作者**一个入口和「继续」**:单入口状态机先跑 git 健康检查(作者永不直面 git 报错,不变量 8),再按序判定当前该做什么(spec §10 序 0-6,命中即停),把 v6 的 8 个命令全部内化为状态流转。状态机只管编排,不判业务、不调 AI(AI 动作备好 DTO 交 M4)。
+
+> 上游法律文本:story-repo-spec §9(中环/外环/例外)、§10(启动序列与状态机)、不变量 8(作者不碰 git)。架构原则 v7-implementation-plan §1.5(状态机只管流程编排)。
+
+## Background(已确认事实,来自 spec §9/§10 + M1/M2 成果)
+
+**启动序列**(§10):第 0 步前先跑 **git 健康检查**——半提交、合并冲突、锁文件、`.git` 损坏、网盘冲突副本(`xxx (1).md`),每种配自动修复或人话指引。然后按序判定,命中即停:
+
+| 序 | 条件 | 动作 | 执行体 |
+|----|------|------|--------|
+| 0 | 任一源文件解析失败 | 修复确认(定位到行 + AI 提议修复 + 作者确认;全角冒号/逗号在结构位 = 确定性错误,预修复后只报不问;永不带栈崩) | 检测=脚本,提议=AI(M4) |
+| 1 | 无书 / 当前书不存在 | 建书引导(问答生成 book.yaml/总纲/第一卷卷纲 + 指路 AGENTS.md + 登记 books.jsonl) | 问答=AI(M4),脚手架=脚本 |
+| 2 | 定稿/大纲有未登记手改 | 提议 fix 补登(`fix(设定): …`) | 脚本(git diff vs 缓存) |
+| 3 | 工作区有未完成流程(含待定稿批次) | 从中断阶段继续 | 脚本(检测工作区状态) |
+| 4 | 刚定稿是卷末章 | 卷复盘 | 清账=脚本,对谈=AI(M4) |
+| 5 | 章号到体检周期 | 体检 | 条目活跃率/时间线孤儿=脚本,文体指纹=M3+/M4 |
+| 6 | 其余 | 起草新章细纲(内环第 1 步) | 全书近况=脚本(M2 已有),拟提案=AI(M4) |
+
+**外环/例外流程**(§9):
+- 影响分析(**纯脚本**):grep 正文+条目履历+时间线,列「哪些章建立在这个事实上」,分已发布/未发布两清单。
+- 回到第 N 章(人话命令):git 回滚包装,执行前展示影响范围 + 作者确认。
+- 吃书 retcon:改定稿,commit `retcon(N): 原因`,设定/条目同步,留痕。
+- 手改检测:即序 2。
+- 分支试写:AI 代开 git 分支 `what-if/…`(git=脚本,推演=AI M4)。
+
+**不变量 8**:作者全程不碰 git 命令,不直面 git 报错。
+
+**M1/M2 已就绪可复用**:M1 读接口(grep-story 给影响分析、reports 给体检/近况)+ `.cache` 重建器;M2 `assembleBookStatus`(序 6 近况)、`finalizeChapter`(git 操作范式 + git.js 封装可扩展)、机检/备料。
+
+**现有空壳**:`v7/src/state-machine/`、`v7/src/installer/`(M0 占位)。
+
+## Requirements(待 brainstorm 收敛,先列骨架)
+
+- R1 git 健康检查:检测半提交/冲突/锁/损坏/网盘副本,各配自动修复或中文指引,永不抛 git 原始报错
+- R2 状态机单入口:按 §10 序 0-6 命中即停判定,返回「当前态 + 该做什么」;状态机不判业务、不调 AI
+- R3 各态检测逻辑(脚本部分):解析失败(0)、无书(1)、未登记手改(2)、工作区未完成(3)、卷末章(4)、体检周期(5)、其余(6)
+- R4 外环纯脚本流程:影响分析、回到第 N 章、吃书 retcon、(分支试写 git 部分)
+- R5 AI 态 DTO 缝:AI 动作(建书问答/修复提议/卷复盘对谈/细纲提案/推演)备好上下文 DTO,返回「需 AI」结果交 M4
+
+## Acceptance Criteria(来自 §10 M3 出口,待细化)
+
+- [ ] 7 个态各有端到端测试(构造命中各序的仓库样本,断言状态机判定正确)
+- [ ] git 异常样本库逐个演练(半提交/冲突/锁/损坏/网盘副本),作者可见输出**零英文堆栈**
+- [ ] 影响分析/回到第 N 章/吃书 纯脚本流程有测试
+- [ ] 不破坏 M1/M2 不变量(删缓存可重建、定稿原子)
+
+## Out of Scope
+
+- ❌ AI 态的真实动作(建书问答/修复提议/卷复盘对谈/细纲提案/分支推演/圆设定)→ M4(M3 只备 DTO + 返回「需 AI」)
+- ❌ 体检的文体指纹提取 → M3+/M4(M3 体检只做条目活跃率/时间线孤儿等脚本项)
+- ❌ 自动模式连写/批次/污染传播 → M6
+- ❌ SessionStart hook 注入实现 → M4/M5(M3 提供状态机入口等价路径)
+
+## 决策记录(brainstorm)
+
+- **D1 范围与粒度**:单任务分期(同 M1/M2)。M3 做满**全部脚本面**——git 健康检查(5 类异常 + 中文指引)、状态机路由器(序 0-6 命中即停)、7 态的检测逻辑、外环纯脚本流程(影响分析 / 回到第N章 / 吃书 retcon / 手改补登 / 分支试写 git 部分)。**AI 态动作(建书问答/修复提议/卷复盘对谈/细纲提案/推演/圆设定)→ 只做「检测 + 备 DTO + 返回需 AI」交 M4**;体检文体指纹提取推 M3+(M3 体检只做条目活跃率/时间线孤儿脚本项)。单入口 = 库函数 `determineNextState(ctx)` + 一个 CLI 命令,SessionStart hook 注入留 M4/M5。分期:git健康 → 路由+7态检测 → 外环脚本流程 → AI 态 DTO 缝。
+
+- **D2 git 健康检查 = 激进自动修复(B)+ 可恢复安全网**:尽量自动修、少打扰作者,但**每个改 git 状态的修复前先做可恢复快照**(stash / 备份 ref / 文件挪到 `工作区/.救援/` 而非删除),修复后给中文小结「修了什么 + 怎么撤」。映射:陈旧锁文件→自动删;网盘副本→自动归档(不删);半提交→自动 stash(可恢复);merge 冲突→先备份再 `merge --abort`;`.git` 损坏→**例外**,无安全自动修,只检测 + 中文指引。激进但零数据丢失。
+
+## Open Questions(brainstorm 待答)
+
+- 无(DTO 形态、手改检测机制、影响分析「已发布」边界等技术细节于 design.md 决,按 M1/M2 风格)。

+ 26 - 0
.trellis/tasks/06-27-m3-state-machine/task.json

@@ -0,0 +1,26 @@
+{
+  "id": "m3-state-machine",
+  "name": "m3-state-machine",
+  "title": "M3 状态机与 git 隐身",
+  "description": "",
+  "status": "in_progress",
+  "dev_type": null,
+  "scope": null,
+  "package": null,
+  "priority": "P2",
+  "creator": "codex",
+  "assignee": "codex",
+  "createdAt": "2026-06-27",
+  "completedAt": null,
+  "branch": null,
+  "base_branch": "v7",
+  "worktree_path": null,
+  "commit": null,
+  "pr_url": null,
+  "subtasks": [],
+  "children": [],
+  "parent": null,
+  "relatedFiles": [],
+  "notes": "",
+  "meta": {}
+}

+ 41 - 0
v7/src/finalize/git.js

@@ -45,5 +45,46 @@ export function createGit(repoPath) {
       const { stdout } = await run(['log', '--oneline', '--no-color'])
       return stdout
     },
+    /** 仓库是否可正常 git status(false = .git 损坏等) */
+    async health() {
+      try {
+        await run(['status', '--porcelain'])
+        return { ok: true, error: '' }
+      } catch (err) {
+        return { ok: false, error: err.message }
+      }
+    },
+    async status() {
+      const { stdout } = await run(['status', '--porcelain'])
+      return stdout
+    },
+    /** 是否处于未完成合并(存在 MERGE_HEAD) */
+    async isMerging() {
+      try {
+        await run(['rev-parse', '-q', '--verify', 'MERGE_HEAD'])
+        return true
+      } catch {
+        return false
+      }
+    },
+    /** 暂存区是否有残留改动(半提交迹象) */
+    async hasStagedChanges() {
+      try {
+        await run(['diff', '--cached', '--quiet'])
+        return false
+      } catch {
+        return true
+      }
+    },
+    async stash(message) {
+      await run(['stash', 'push', '-m', message])
+    },
+    async mergeAbort() {
+      await run(['merge', '--abort'])
+    },
+    /** 建救援 ref:refs/<name> 指向 source(默认 HEAD),用于回滚前备份 */
+    async createBackupRef(name, source = 'HEAD') {
+      await run(['update-ref', `refs/${name}`, source])
+    },
   }
 }

+ 124 - 0
v7/src/state-machine/git-health.js

@@ -0,0 +1,124 @@
+import { promises as fs } from 'node:fs'
+import path from 'node:path'
+import { createGit } from '../finalize/git.js'
+
+const STALE_LOCK_MS = 3000
+
+/**
+ * git 健康检查(spec §10 第 0 步前)。D2:激进自动修 + 可恢复安全网。
+ * 作者永不直面 git 英文报错(不变量 8):返回中文 fixed/guidance/rescued。
+ * @param {{repoPath: string}} ctx
+ * @returns {Promise<{ok: boolean, fixed: string[], guidance: string[], rescued: string[]}>}
+ */
+export async function checkGitHealth(ctx) {
+  const { repoPath } = ctx
+  const git = createGit(repoPath)
+  const fixed = []
+  const guidance = []
+  const rescued = []
+
+  // 1. 陈旧锁文件(先于 git status,免得残留锁干扰检测)
+  const lockPath = path.join(repoPath, '.git', 'index.lock')
+  try {
+    const st = await fs.stat(lockPath)
+    if (Date.now() - st.mtimeMs > STALE_LOCK_MS) {
+      await fs.rm(lockPath, { force: true })
+      fixed.push('删除了陈旧的 git 锁文件(.git/index.lock,残留自上次中断的 git 操作)')
+      rescued.push('index.lock 已删')
+    }
+  } catch {
+    // 无锁文件
+  }
+
+  // 2. .git 损坏:坏了别再动 git(D2 例外,只指引)
+  const health = await git.health()
+  if (!health.ok) {
+    guidance.push(
+      'git 仓库似乎损坏,无法安全自动修复。请先把整个书目录复制一份做备份,再尝试 `git fsck`,或从网盘/最近备份找回历史版本。'
+    )
+    await logRescue(repoPath, '检测到 .git 损坏:仅给指引,未自动改动仓库')
+    return { ok: true, fixed, guidance, rescued }
+  }
+
+  // 3. 网盘冲突副本 → 归档(移动非删除)
+  for (const f of await findCloudDupes(repoPath)) {
+    const rel = path.relative(repoPath, f)
+    const dest = path.join(repoPath, '工作区', '.救援', '网盘副本', path.basename(f))
+    try {
+      await fs.mkdir(path.dirname(dest), { recursive: true })
+      await fs.rename(f, dest)
+      fixed.push(`网盘冲突副本「${rel}」已归档到 工作区/.救援/网盘副本/(未删除,可找回)`)
+      rescued.push(rel)
+    } catch (err) {
+      guidance.push(`发现疑似网盘冲突副本「${rel}」,自动归档失败,请手动检查(${err.message})`)
+    }
+  }
+
+  // 4. 合并冲突 → 备份待合并分支后中止
+  if (await git.isMerging()) {
+    const ref = `rescue/merge-${Date.now()}`
+    try {
+      await git.createBackupRef(ref, 'MERGE_HEAD')
+      await git.mergeAbort()
+      fixed.push(
+        `检测到未完成的合并,已把待合并分支备份到 refs/${ref} 并中止合并。如需重试合并:git merge refs/${ref}`
+      )
+      rescued.push(ref)
+    } catch (err) {
+      guidance.push(`检测到未完成的合并,自动中止失败,请联系维护者(${err.message})`)
+    }
+  } else if (await git.hasStagedChanges()) {
+    // 5. 半提交(暂存残留)→ stash(可恢复)
+    const msg = `半提交救援-${Date.now()}`
+    try {
+      await git.stash(msg)
+      fixed.push(
+        `检测到未完成的提交(暂存区有残留改动),已暂存到 git stash「${msg}」。如需取回:git stash pop`
+      )
+      rescued.push(msg)
+    } catch (err) {
+      guidance.push(`检测到未完成的提交,自动暂存失败,请手动检查(${err.message})`)
+    }
+  }
+
+  if (fixed.length || rescued.length) {
+    await logRescue(repoPath, fixed.join(';'))
+  }
+  return { ok: true, fixed, guidance, rescued }
+}
+
+// 扫描 定稿/大纲/文风,找网盘冲突副本(xxx (1).md / xxx 的冲突副本)
+async function findCloudDupes(repoPath) {
+  const all = []
+  for (const sub of ['定稿', '大纲', '文风']) {
+    all.push(...(await walk(path.join(repoPath, sub))))
+  }
+  return all.filter((f) => /(\(\d+\)\.md$)|的冲突副本/.test(path.basename(f)))
+}
+
+async function walk(dir) {
+  let result = []
+  let entries
+  try {
+    entries = await fs.readdir(dir, { withFileTypes: true })
+  } catch {
+    return result
+  }
+  for (const e of entries) {
+    const full = path.join(dir, e.name)
+    if (e.isDirectory()) result = result.concat(await walk(full))
+    else result.push(full)
+  }
+  return result
+}
+
+async function logRescue(repoPath, line) {
+  if (!line) return
+  try {
+    const dir = path.join(repoPath, '工作区', '.救援')
+    await fs.mkdir(dir, { recursive: true })
+    await fs.appendFile(path.join(dir, '修复日志.md'), `- ${line}\n`, 'utf8')
+  } catch {
+    // 日志失败不影响主流程
+  }
+}

+ 114 - 0
v7/test/state-machine/git-health.test.js

@@ -0,0 +1,114 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import path from 'node:path'
+import os from 'node:os'
+import { promises as fs } from 'node:fs'
+import { execFile } from 'node:child_process'
+import { promisify } from 'node:util'
+import { checkGitHealth } from '../../src/state-machine/git-health.js'
+
+const execFileAsync = promisify(execFile)
+
+// 造一个健康的 git 书仓库
+async function makeGitRepo() {
+  const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wnw-gh-'))
+  const git = (args) => execFileAsync('git', args, { cwd: root })
+  await git(['init', '-q'])
+  await git(['config', 'user.email', 't@example.com'])
+  await git(['config', 'user.name', 'test'])
+  await fs.mkdir(path.join(root, '定稿', '正文'), { recursive: true })
+  await fs.writeFile(path.join(root, '定稿', '正文', '0001-开局.md'), '---\n章号: 1\n---\n正文', 'utf8')
+  await fs.writeFile(path.join(root, 'book.yaml'), '书名: 测\n', 'utf8')
+  await fs.writeFile(path.join(root, '.gitignore'), '.cache/\n工作区/\n', 'utf8')
+  await git(['add', '-A'])
+  await git(['commit', '-q', '-m', 'init'])
+  return { root, git }
+}
+
+test('git健康:陈旧锁文件自动删 + 救援记录', async () => {
+  const { root } = await makeGitRepo()
+  try {
+    const lock = path.join(root, '.git', 'index.lock')
+    await fs.writeFile(lock, '', 'utf8')
+    const old = new Date(Date.now() - 3600_000)
+    await fs.utimes(lock, old, old)
+    const r = await checkGitHealth({ repoPath: root })
+    assert.equal(r.ok, true)
+    assert.ok(r.fixed.some((m) => m.includes('锁文件')))
+    await assert.rejects(() => fs.access(lock))
+  } finally {
+    await fs.rm(root, { recursive: true, force: true })
+  }
+})
+
+test('git健康:网盘冲突副本归档不删', async () => {
+  const { root } = await makeGitRepo()
+  try {
+    const dupe = path.join(root, '定稿', '正文', '0001-开局 (1).md')
+    await fs.writeFile(dupe, '副本内容', 'utf8')
+    const r = await checkGitHealth({ repoPath: root })
+    assert.ok(r.fixed.some((m) => m.includes('网盘')))
+    await assert.rejects(() => fs.access(dupe))
+    const archived = path.join(root, '工作区', '.救援', '网盘副本', '0001-开局 (1).md')
+    assert.equal(await fs.readFile(archived, 'utf8'), '副本内容')
+  } finally {
+    await fs.rm(root, { recursive: true, force: true })
+  }
+})
+
+test('git健康:半提交(暂存残留)自动 stash 可恢复', async () => {
+  const { root, git } = await makeGitRepo()
+  try {
+    await fs.writeFile(path.join(root, '定稿', '正文', '0002-初遇.md'), '---\n章号: 2\n---\n新章', 'utf8')
+    await git(['add', '-A'])
+    const r = await checkGitHealth({ repoPath: root })
+    assert.ok(r.fixed.some((m) => m.includes('暂存') || m.includes('stash')))
+    const { stdout } = await execFileAsync('git', ['status', '--porcelain'], {
+      cwd: root,
+      encoding: 'utf8',
+    })
+    assert.equal(stdout.trim(), '')
+    const { stdout: stashList } = await execFileAsync('git', ['stash', 'list'], {
+      cwd: root,
+      encoding: 'utf8',
+    })
+    assert.ok(stashList.includes('救援'))
+  } finally {
+    await fs.rm(root, { recursive: true, force: true })
+  }
+})
+
+test('git健康:合并冲突先备份再中止', async () => {
+  const { root, git } = await makeGitRepo()
+  try {
+    await git(['checkout', '-q', '-b', 'other'])
+    await fs.writeFile(path.join(root, 'book.yaml'), '书名: 分支A\n', 'utf8')
+    await git(['commit', '-q', '-am', 'A'])
+    await git(['checkout', '-q', '-'])
+    await fs.writeFile(path.join(root, 'book.yaml'), '书名: 分支B\n', 'utf8')
+    await git(['commit', '-q', '-am', 'B'])
+    try {
+      await git(['merge', 'other'])
+    } catch {
+      // 预期冲突
+    }
+    const r = await checkGitHealth({ repoPath: root })
+    assert.ok(r.fixed.some((m) => m.includes('合并')))
+    await assert.rejects(() => fs.access(path.join(root, '.git', 'MERGE_HEAD')))
+  } finally {
+    await fs.rm(root, { recursive: true, force: true })
+  }
+})
+
+test('git健康:.git 损坏只指引不乱动(零英文堆栈)', async () => {
+  const { root } = await makeGitRepo()
+  try {
+    await fs.writeFile(path.join(root, '.git', 'HEAD'), '损坏内容不是 ref', 'utf8')
+    const r = await checkGitHealth({ repoPath: root })
+    assert.equal(r.ok, true)
+    assert.ok(r.guidance.some((m) => m.includes('损坏') || m.includes('备份')))
+    assert.ok(r.guidance.every((m) => !/Error|fatal|\bstack\b/i.test(m)))
+  } finally {
+    await fs.rm(root, { recursive: true, force: true })
+  }
+})