소스 검색

chore(task): M1-M5 全量 review 报告与探针入库,附修复记录

逐文件精读+真 CLI 探针行为级复现:P1×3(卷复盘不 commit/四处改源不刷缓存/
序2 无执行通道)、P2×6、S×2,全部本任务内收口;测试盲区定性为
「流程自身绿、流程间接力没人测」,已以 relay.test.js 固化。
lingfengQAQ 11 시간 전
부모
커밋
f9684f2e6d
2개의 변경된 파일150개의 추가작업 그리고 0개의 파일을 삭제
  1. 75 0
      .trellis/tasks/07-03-m5-installer/review-m1-m5.md
  2. 75 0
      .trellis/tasks/07-03-m5-installer/review-probe.mjs

+ 75 - 0
.trellis/tasks/07-03-m5-installer/review-m1-m5.md

@@ -0,0 +1,75 @@
+# M1–M5 全量 Review 报告(2026-07-03)
+
+> 方法:沿 06-29 深审路数——重装 spec §8/§9/§10 与两份既往 review 作基线,逐文件精读(重点 M5 新面 94 源文件中的新增/改动 + 跨里程碑接缝),再用真 CLI 子进程探针(`review-probe.mjs`,本目录)实跑边角流程验证。全量 335 测试绿、drift 绿、CI run 28663338317 六 job 绿为前提。
+
+## 结论速览
+
+M1-M4 上两轮 review 修掉的 P0/P1/P2 全部仍在位(重建事务 BEGIN/ROLLBACK、文件名净化、同章去重、goto 脏树拒绝、finalize 回滚收窄、条目形式检查——逐条复核过)。M5 的安装器/多本书/F1 主链路(init→建书→写章→定稿→next)经双平台 CI 与本地 e2e 验证成立。
+
+**但「主循环之外的三条支路」存在成环缺口**,全部行为级复现确认:卷复盘与手改补登两个态走完会把作者困在序 2 死胡同;四个改源文件的流程不刷缓存,其中 goto 回退后 next 会**起草错误章号**。性质与上轮 review 的 P0-1/P0-2 同族——测试只测了每个流程自身,没测「流程走完之后 next 还对不对」。
+
+---
+
+## P1(成环缺口,建议本任务内修)
+
+### P1-1 卷复盘产物不 commit → `next` 误触序 2(spec §9 违约)
+- spec §9 明文:卷复盘产出走 **`vol(05): 复盘与下卷规划`** commit。`persistVolumeReview`(`src/state-machine/persist.js:62`)只 `writeAtomicBatch`,不 add/commit。
+- 探针 1 复现:`persist-volume-review` 成功 → `next --json` → **序 2 relink-manual-edits**(卷摘要/下卷卷纲/新伏笔被当成手改)。
+- 与 M5 修过的「建书不 commit 误触序 2」完全同族——当时只修了序 1,序 4 漏了。
+- 修法:`persistVolumeReview` 落盘后 `ensureIdentity → add(written) → commit('vol(N): 卷复盘')`(书仓库此时必已是 git 仓库)。
+
+### P1-2 改源文件的流程不刷缓存 → 陈旧数据驱动后续判定
+`ensureReady`(`src/cache/index.js:23`)只在 db 缺失/空表/schema 不匹配时重建——**正确前提是"所有改源的路径自己负责刷新"**,目前只有 `finalizeChapter` 做到。四个缺口,两条已探针确认:
+
+| 流程 | 改了什么 | 陈旧后果 | 探针 |
+|---|---|---|---|
+| `goto-chapter --confirm`(`flows/goto-chapter.js`) | 工作树回退到旧 commit | **`next` 用旧 maxChapter 起草错章**(回到第 1 章后报「起草第 3 章」);精准读取读幽灵章 | 探针 2 确认 |
+| `persistVolumeReview` | 新增伏笔条目文件 | 新伏笔在 `list-threads`/备料/两审里**不可见**,直到下次定稿 | 探针 1b 确认 |
+| `persistRepair` | 修复解析失败的源文件 | 修复件本就因解析失败不在缓存,修完仍不进——报表/备料继续缺数据 | 代码级确认 |
+| `retcon`(`flows/retcon.js`) | 改角色/条目文件并 commit | threads/entities 陈旧 | 代码级确认 |
+
+- 修法:四处成功路径尾部统一 `cache.rebuildFromSource(repoPath, { keepExistingOnFailure: false })`,与 finalize 同法(ctx.cache 四处都已可得;goto/retcon 在 flow 层收 ctx)。
+
+### P1-3 序 2 手改补登无执行通道(死胡同)
+- spec §9:手改检测后「细纲前问一句『补登吗』,确认后 **`fix(设定): …` 入档**」。
+- 现状:序 2 返回 `needsAI=false, dto={}`(探针 3),既无补登 CLI,AI 又被铁律禁止直接跑 git——**作者确认补登后系统无事可做**。persist-repair 修复完(P1-2 之外)也落进同一死胡同:修复件永远躺在未提交区,每次 `next` 都报序 2。
+- 修法:新命令 `relink --message=<说明>`(scope=book):`add(定稿/, 大纲/) → commit('fix(手改): <说明>') → rebuildFromSource`;`next` 序 2 的 dto 附变更文件清单(`git status` 已扫过,白给);SKILL 序 2 行接上该命令。
+
+## P2(不阻断,修则更稳)
+
+- **P2-1 `--file`/`--payload` 相对路径基准反直觉且两套**:书级命令按 repoPath 解析、persist-book 按 workdir——宿主在启动目录写临时 JSON 传相对名必 ENOENT(探针初版亲历踩坑)。修:`json-input.js` 先按 cwd 解析,不存在再按 repoPath,错误信息列出两处找过的路径。
+- **P2-2 `--draft` 传绝对路径会拼坏**:`save-review` 用 `path.join(ctx.repoPath, draftRel)`、`assembleReviewInput` 同(`review/index.js:27`);`path.join` 遇绝对路径产出垃圾路径。mechanical-check 用的 `path.resolve` 是对的。修:统一 `path.resolve`。
+- **P2-3 安装器 manifest 丢「本轮未涉及宿主」的旧记录**(`installer/index.js:70` 起 newFiles 只收本轮文件集):`init --hosts=codex` 之后 `update --hosts=claude-code` 会把 .codex 记录从清单剔除;用户此后手改 .codex 文件,再来一次全量 update 时被判 `new` **静默覆盖**,违反「不静默覆盖手改」。修:`newFiles` 以旧 manifest.files 为底再覆盖本轮。
+- **P2-4 persist-book 书名即目录名未净化**:书名含 Windows 非法字符(`:?"*<>|`)时 mkdir 深处报错,人话但不指路。修:复用 `ChapterWriter.sanitizeFileName` 的净化思路或前置校验给指引。另:书仓库直启模式对同内容二次 persist-book 会因 `nothing to commit` 报「建书落盘失败」(开发/测试路径,低害)。
+- **P2-5 序 3 resume 的 dto 为空对象**:宿主只能靠一句 message 猜断点在哪。工作区文件存在性(细纲/草稿/审稿单/待定稿)检测时已扫,附进 dto 即可。
+- **P2-6 `prep/index.js:97` 注释「随 M3+ 体检补」已过时**(应为 M5.5);同类:`session-context` 后 books.jsonl 的自愈回写在每个书级命令都可能发生(loadBooks),并发跑两条命令理论上有 last-write-wins——登记可重建(spec 可再生),接受现状,注明即可。
+
+## S(spec/文档层)
+
+- **S-1** P1-1/P1-3 修复后,story-repo spec §10 状态表应注明序 2/序 4 的**执行责任落点**(脚本 commit:`fix(手改)`/`vol(N)`),与序 8 定稿同格式——两轮 review 都在这类「执行体归属」上抓到漏洞,值得钉死。
+- **S-2** 多宿主 spec §7.1 registry 示例未含 M5 新字段 `detect_bin`/`install_dir`(validator 已强制)——示例是说明性的,但下次照抄示例造 registry 会被 validator 打回,建议顺手补。
+
+## 测试盲区分析(为什么这轮还有漏)
+
+上轮教训是「测试脚手架替生产代码干活」;这轮的形态是**「流程自身绿,流程间的接力没人测」**——e2e 只走了 建书→写章→定稿→next 主环,序 0/2/4 与 goto/retcon 走完后的 `next` 从未被断言。修复时应各补一条「流程完→next 判定正确」的接力测试(P1-1/2/3 各一),并把探针 2 的 goto 场景固化进 m3-flows 测试。
+
+## 各里程碑健康度
+
+| 里程碑 | 状态 | 备注 |
+|---|---|---|
+| M1 格式层/缓存 | 健康 | 事务、容错、41 接口无回归;`ensureReady` 语义正确但依赖"改源者自刷"约定——P1-2 是违约方不是它 |
+| M2 脚本面 | 健康 | 备料/机检/定稿无新发现;机检条目形式检查(边界任务补)在位 |
+| M3 状态机/git 隐身 | **两处成环缺口** | P1-2(goto/retcon 缓存)、P1-3(序 2 死胡同)都源出 M3 范围,被 F1 缺 CLI 掩盖至今 |
+| M4 AI 层/壳 | 健康 | schema 单源、降级诚实、drift 在位;ReviewInput 别名命中(P1-1 修)在位 |
+| M5 安装器/F1 | 主链健康,**接力缺口** | P1-1(卷复盘 commit 漏做)、P2-1/2/3/4 均属 M5 新面自身 |
+
+---
+
+## 修复记录(2026-07-04,本任务内收口)
+
+全部发现已修复并验证(346 测试绿 = 335 基线 + 11 新增;真 CLI 探针复跑三条 P1 全闭合):
+
+- **P1-1/P1-2/P1-3**:`persistVolumeReview` 补 `vol(NN)` commit + 刷缓存;`refreshCacheAfterSourceChange` 统一助手接入 finalize/goto/retcon/卷复盘/修复回写五处;新增 `relink --message` 补登命令,序 2 的 message/dto 带变更清单并指路,SKILL 序 2/序 3 行接通 dto。
+- **P2-1**:json-input 相对路径先 cwd 再书仓库,报错列出两处候选。**P2-2**:save-review/review-input 的 `--draft` 改 `path.resolve`。**P2-3**:安装器新清单以旧清单为底,未涉及宿主记录不再丢。**P2-4**:persist-book 书名非法字符前置拦截指路 `--dir`;建书/卷复盘 commit 前查 staged,二次运行幂等。**P2-5**:序 3 dto 带 `工作区现存`/`从哪继续`。**P2-6**:注释修正 + loadBooks 并发 last-write-wins 注明接受。
+- **S-1**:story-repo spec 0.10(决策 33/34:序 2/序 4 执行责任落点、改源自刷缓存公约)。**S-2**:多宿主 spec v3.6(§7.1 registry 示例补 `detect_bin`/`install_dir` 等 M5 实态字段)。
+- **接力测试**:`test/state-machine/relay.test.js` 六条「流程完 → next 判定正确」用例固化(含探针 2 goto 场景);探针 1b 的「list-threads 不可见」复跑证实是探针裸调用报错,非缓存陈旧——带 `--type=foreshadow` 立即可见。

+ 75 - 0
.trellis/tasks/07-03-m5-installer/review-probe.mjs

@@ -0,0 +1,75 @@
+// M1-M5 review 行为探针:真 CLI 子进程跑边角流程,验证代码层疑点。跑完自删临时目录。
+import { execFile } from 'node:child_process'
+import { promisify } from 'node:util'
+import { promises as fs } from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const exec = promisify(execFile)
+const BIN = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'webnovel-writer.js')
+
+const workdir = await fs.mkdtemp(path.join(os.tmpdir(), 'wnw-probe-'))
+await fs.mkdir(path.join(workdir, '.webnovel'), { recursive: true })
+const run = async (args, expectFail = false) => {
+  try {
+    const r = await exec(process.execPath, [BIN, ...args], { cwd: workdir, encoding: 'utf8' })
+    return { code: 0, out: r.stdout, err: r.stderr }
+  } catch (e) {
+    if (!expectFail) console.error(`命令失败 ${args.join(' ')}:\n${e.stderr || e.message}`)
+    return { code: e.code ?? 1, out: e.stdout || '', err: e.stderr || '' }
+  }
+}
+const j = async (name, data) => {
+  const p = path.join(workdir, name)
+  await fs.writeFile(p, JSON.stringify(data), 'utf8')
+  return p
+}
+const repo = path.join(workdir, '测')
+
+try {
+  // 建书 + 定稿两章
+  const f建 = await j('建书.json', { book: { spec_version: '7.0', 书名: '测', 卷规模: 40, 体检周期: 50 }, 总纲: '# 总纲\n## 结局\nx', 卷纲: '# 第1卷\ny' })
+  await run(['persist-book', `--file=${f建}`])
+  for (const n of [1, 2]) {
+    const fp = await j(`定稿${n}.json`, {
+      frontMatter: { 章号: n, 标题: `第${n}章`, 卷: 1, 字数: 10, 章定位: '推进' },
+      body: `第${n}章正文。`, summary: `摘要${n}`, commitLines: {}, workspaceFiles: [],
+    })
+    const r = await run(['finalize', String(n), `--payload=${fp}`])
+    if (!r.out.includes('已定稿')) console.error(`定稿${n}失败: ${r.err}`)
+  }
+
+  // 探针1:卷复盘产物落盘后 next → 是否误触序2
+  const f卷 = await j('卷复盘.json', { 卷号: 1, 卷摘要: '# 第01卷复盘\n清账。', 下卷卷纲: '# 第2卷\nz', 伏笔条目: [{ id: '伏笔-001-试', frontMatter: { id: '伏笔-001', 短题: '试', 状态: '进行', 开启章: 2 }, body: '收尾计划:第10章' }] })
+  const pv = await run(['persist-volume-review', `--file=${f卷}`])
+  console.log(`persist-volume-review: ${pv.code === 0 ? 'ok' : pv.err}`)
+  const n1 = await run(['next', '--json'])
+  const s1 = JSON.parse(n1.out)
+  console.log(`探针1 卷复盘后 next → 序${s1.序} ${s1.state}  ${s1.序 === 2 ? '【误触序2 确认】' : ''}`)
+
+  // 探针1b:新伏笔条目在缓存里可见吗(陈旧性)
+  const lt = await run(['list-threads'], true)
+  console.log(`探针1b 卷复盘新伏笔 list-threads 可见: ${lt.out.includes('伏笔-001') ? '可见' : '【不可见=缓存陈旧 确认】'}`)
+
+  // 收拾:把卷复盘产物 commit 掉,让探针2 干净
+  const git = (a) => exec('git', a, { cwd: repo })
+  await git(['add', '-A'])
+  await git(['commit', '-q', '-m', 'vol(1): probe'])
+
+  // 探针2:goto-chapter 1 --confirm 后 next → 起草第几章
+  const g = await run(['goto-chapter', '1', '--confirm'])
+  console.log(`goto: ${(g.out || g.err).split('\n')[0]}`)
+  const n2 = await run(['next', '--json'])
+  const s2 = JSON.parse(n2.out)
+  const next2 = s2.dto ? s2.dto.nextChapter : undefined
+  console.log(`探针2 回到第1章后 next → 序${s2.序} nextChapter=${next2}  ${next2 === 3 ? '【缓存领先,起草错章 确认】' : next2 === 2 ? '正常' : `state=${s2.state}`}`)
+
+  // 探针3:手改一个已跟踪文件 → next 序2 → 有无补登通道
+  await fs.appendFile(path.join(repo, '大纲', '总纲.md'), '\n作者手改。', 'utf8')
+  const n3 = await run(['next', '--json'])
+  const s3 = JSON.parse(n3.out)
+  console.log(`探针3 手改后 next → 序${s3.序} ${s3.state} message=${s3.message} dto=${JSON.stringify(s3.dto)}`)
+} finally {
+  await fs.rm(workdir, { recursive: true, force: true, maxRetries: 3 }).catch(() => {})
+}