Просмотр исходного кода

feat(v7): M6 P4——状态机批次感知、SKILL 自动模式段与开关矩阵

- 序 3 dto 批次明细(章清单/停止判定/续跑建议:重写打回/重审受影响/批量过稿/继续下一章)
- 序 6 dto 自动确认细纲标志 + 期望产物文案条件化
- SKILL.md 增「自动模式(连写)」段:批内循环、stage 暂存、批末呈报与作者三形态裁决、batch-discard 确认门
- 开关矩阵测试(AC5):全关零变化/单开细纲/单开连写;全开=finalize-batch AC1
- 三平台壳重渲染 + drift check 绿;全量 407 测试绿
lingfengQAQ 9 часов назад
Родитель
Сommit
28acfd25a8

+ 12 - 1
v7/skills/webnovel-writer/SKILL.md

@@ -36,7 +36,18 @@ SessionStart 已注入「当前在写哪本 / 共几本 / 全书近况入口」
    兼容模式——按 `事实审查`、`编辑审` 两份任务书顺序自审,`mode` 填 `degraded`。
 {{/unless}}
    两份报告合成 `{"事实审查","编辑审","mode","待确认新专名","章摘要"}`,运行 `{{cmd}} save-review <章号> --file=<json路径>`;审稿单落 `工作区/审稿.md`,交作者:接受 / 改完接受 / 打回。
-4. 定稿:作者敲定后组定稿包(`frontMatter`、`body`、`summary`、`threadUpdates`、`characterUpdates`、`rosterUpserts`、`timelineRows`、`secretWrites`、`commitLines`、`workspaceFiles`——本章用过的工作区文件全列进 `workspaceFiles`),运行 `{{cmd}} finalize <章号> --payload=<json路径>`,再运行 `{{cmd}} next --json` 进下一步。
+4. 定稿:作者敲定后组定稿包(`frontMatter`、`body`、`summary`、`threadCreates`(本章「埋下/设下/开启」的新条目,`{id, 短题, frontMatter, body}`)、`threadUpdates`、`characterUpdates`、`rosterUpserts`、`timelineRows`、`secretWrites`、`commitLines`、`workspaceFiles`——本章用过的工作区文件全列进 `workspaceFiles`),运行 `{{cmd}} finalize <章号> --payload=<json路径>`,再运行 `{{cmd}} next --json` 进下一步。
+
+## 自动模式(连写,作者说「连写/挂机写一批」才进入)
+1. 批内每章走写章流程 1-3;`next --json` 返回 `dto.自动确认细纲 = true` 时细纲提案直接 `{{cmd}} persist-outline` 生效,不问作者。
+2. 每章两审 save-review 后不 finalize:组同样的定稿包运行 `{{cmd}} stage-chapter <章号> --payload=<json路径>` 暂存(批内材料/审稿/机检自动叠加「定稿+批内预登记」,后章可用前章事实)。
+3. 按 stage-chapter 返回走:停止条件未命中 → `{{cmd}} next --json` 继续下一章;命中(写满/收卷/卷纲耗尽/连续无条目变动/批次质检不过线)→ 停止连写。
+4. 停止后运行 `{{cmd}} batch-status` 向作者呈报,按作者裁决执行:
+   - 整批接受 → `{{cmd}} finalize-batch`(逐章按序原子入档;`--until=<章号>` 只转正前段)。
+   - 改某几章 → 改完重跑两审与 `{{cmd}} stage-chapter` 覆盖,再 finalize-batch。
+   - 从第 K 章起打回 → `{{cmd}} batch-reject <K>`;重写 K 后 stage-chapter 覆盖,受影响章重跑两审后 `{{cmd}} batch-restage <章号>`,全部回「待审收」再 finalize-batch。
+5. 作者明确说整批不要 → 再次确认后 `{{cmd}} batch-discard`(未入档,定稿零变化)。
+6. 中断后作者说「继续」:`next` 序 3 返回 `dto.批次`,按其 `建议` 字段续跑。
 
 ## 例外流程
 - 回到第N章:`{{cmd}} goto-chapter <章号>`,先备份再回滚。

+ 38 - 1
v7/src/state-machine/dto.js

@@ -1,6 +1,8 @@
 import { promises as fs } from 'node:fs'
 import path from 'node:path'
 import { assembleBookStatus } from '../prep/book-status.js'
+import { BookConfigReader } from '../storage/adapters/BookConfigReader.js'
+import { readBatch, judgeStop, 章状态 } from '../staging/index.js'
 
 /**
  * 为 AI 态组装上下文 DTO(M3 只备料,不调 AI)。M4 吃 DTO 出结构化产物,
@@ -35,6 +37,7 @@ export async function buildDto(ctx, 序, base = {}) {
         state: 'resume',
         工作区现存: base.现存 || [],
         从哪继续: base.从哪继续 || '',
+        ...(await batchDetail(ctx, base)),
         期望产物: '按「从哪继续」回到写章流程对应步骤(spec §10 续跑映射)',
       }
     case 4: {
@@ -49,11 +52,16 @@ export async function buildDto(ctx, 序, base = {}) {
     }
     case 6: {
       const status = await assembleBookStatus(ctx)
+      const config = await new BookConfigReader(ctx.repoPath).read()
+      const 自动确认细纲 = !!(config.ok && config.data.自动确认细纲)
       return {
         state: 'draft-outline',
         nextChapter: base.nextChapter,
         全书近况: status.ok ? status.markdown : '',
-        期望产物: '工作区/细纲.md(含本章定位声明 + 本章要写到的事 + 备选,由 M3 落盘);卷近尾声时提案可含收卷提议(依据卷纲进度与卷规模参考值,作者确认后定稿写入 收卷: 是)',
+        自动确认细纲,
+        期望产物: 自动确认细纲
+          ? '工作区/细纲.md(含本章定位声明 + 本章要写到的事 + 备选,由 M3 落盘);自动确认细纲已开:提案直接 persist-outline 生效,不再问作者;卷近尾声时提案可含收卷提议'
+          : '工作区/细纲.md(含本章定位声明 + 本章要写到的事 + 备选,由 M3 落盘);卷近尾声时提案可含收卷提议(依据卷纲进度与卷规模参考值,作者确认后定稿写入 收卷: 是)',
       }
     }
     default:
@@ -61,6 +69,35 @@ export async function buildDto(ctx, 序, base = {}) {
   }
 }
 
+// 序 3 的待定稿批次明细(无批次时不加字段)
+async function batchDetail(ctx, base) {
+  if (!(base.现存 || []).includes('待定稿/')) return {}
+  const batch = await readBatch(ctx.repoPath)
+  if (!batch.exists) return {}
+  const 打回 = batch.章列表.filter((r) => r.状态 === 章状态.打回).map((r) => r.章号)
+  const 受影响 = batch.章列表.filter((r) => r.状态 === 章状态.受影响).map((r) => r.章号)
+  const 停止 = await judgeStop(ctx, batch)
+  let 建议
+  if (打回.length) {
+    建议 = `重写打回章(第 ${打回.join('、')} 章):走写章流程后 stage-chapter 覆盖`
+  } else if (受影响.length) {
+    建议 = `重审受影响章(第 ${受影响.join('、')} 章):重跑两审 save-review 后 batch-restage`
+  } else if (停止.stop) {
+    建议 = '批次已停:batch-status 呈报作者批量过稿,裁决后 finalize-batch'
+  } else {
+    建议 = `继续批内下一章(第 ${batch.章列表[batch.章列表.length - 1].章号 + 1} 章)`
+  }
+  return {
+    批次: {
+      起章: batch.起章,
+      章数: batch.章列表.length,
+      章: batch.章列表.map((r) => ({ 章号: r.章号, 标题: r.标题, 状态: r.状态 })),
+      停止,
+      建议,
+    },
+  }
+}
+
 async function whatsMissing(ctx) {
   if (!ctx.repoPath) return ['book.yaml', '总纲'] // 空工作目录:书仓库还不存在
   const missing = []

+ 116 - 0
v7/test/state-machine/auto-mode.test.js

@@ -0,0 +1,116 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import path from 'node:path'
+import { promises as fs } from 'node:fs'
+import { determineNextState } from '../../src/state-machine/index.js'
+import { stageChapter, rejectFrom } from '../../src/staging/index.js'
+import { gitBookCtx } from '../commands/_helper.js'
+
+// 开关矩阵(PRD #14):自动确认细纲 × 连写。全关=既有回归(router.test.js 全量);
+// 本文件测:单开细纲自动确认的序 6 标志、批次进行中的序 3 明细。全开端到端见 finalize-batch.test.js AC1。
+
+async function reachOutline(ctx) {
+  // sample-book 工作区自带细纲 → 会命中序 3,清掉以到达序 6
+  await fs.rm(path.join(ctx.repoPath, '工作区', '细纲.md'), { force: true })
+}
+
+async function setBookYaml(ctx, key, value) {
+  const p = path.join(ctx.repoPath, 'book.yaml')
+  const src = await fs.readFile(p, 'utf8')
+  const line = `${key}: ${value}`
+  const re = new RegExp(`^${key}:.*$`, 'm')
+  await fs.writeFile(p, re.test(src) ? src.replace(re, line) : `${src}${line}\n`, 'utf8')
+}
+
+async function stage3(ctx) {
+  await fs.mkdir(path.join(ctx.repoPath, '工作区'), { recursive: true })
+  await fs.writeFile(
+    path.join(ctx.repoPath, '工作区', '审稿.md'),
+    '# 第 3 章审稿单\n\n> 完整两审模式。\n> 共 0 个问题:0 阻断。\n',
+    'utf8'
+  )
+  const r = await stageChapter(ctx, {
+    chapterNum: 3,
+    payload: {
+      frontMatter: {
+        章号: 3,
+        标题: '连写3',
+        卷: 1,
+        书内时间: '夏月初三',
+        字数: 100,
+        章定位: '推进',
+        钩子: '危机钩-强',
+        情绪定位: '铺垫',
+        伏笔: ['推进 伏笔-001'],
+      },
+      body: '第3章正文。',
+      workspaceFiles: [],
+    },
+  })
+  assert.equal(r.ok, true, r.error)
+}
+
+test('开关矩阵:全关 → 序 6 dto.自动确认细纲=false,行为与既有一致', async () => {
+  const { ctx, cleanup } = await gitBookCtx()
+  try {
+    await reachOutline(ctx)
+    const r = await determineNextState(ctx)
+    assert.equal(r.序, 6, JSON.stringify(r))
+    assert.equal(r.dto.自动确认细纲, false)
+    assert.match(r.dto.期望产物, /作者确认/)
+  } finally {
+    await cleanup()
+  }
+})
+
+test('开关矩阵:自动确认细纲开 → 序 6 dto 标志与提案直接生效指引', async () => {
+  const { ctx, cleanup } = await gitBookCtx()
+  try {
+    await reachOutline(ctx)
+    await setBookYaml(ctx, '自动确认细纲', 'true')
+    const r = await determineNextState(ctx)
+    assert.equal(r.序, 6, JSON.stringify(r))
+    assert.equal(r.dto.自动确认细纲, true)
+    assert.match(r.dto.期望产物, /不再问作者/)
+  } finally {
+    await cleanup()
+  }
+})
+
+test('批次进行中:序 3 dto.批次 带章清单与「继续下一章」建议;打回后建议重写', async () => {
+  const { ctx, cleanup } = await gitBookCtx()
+  try {
+    await reachOutline(ctx)
+    await stage3(ctx)
+    const r = await determineNextState(ctx)
+    assert.equal(r.序, 3, JSON.stringify(r))
+    assert.equal(r.state, 'resume')
+    assert.equal(r.dto.从哪继续, '待定稿批次续跑')
+    assert.equal(r.dto.批次.章数, 1)
+    assert.deepEqual(r.dto.批次.章, [{ 章号: 3, 标题: '连写3', 状态: '待审收' }])
+    assert.match(r.dto.批次.建议, /继续批内下一章(第 4 章)/)
+
+    const rej = await rejectFrom(ctx.repoPath, 3)
+    assert.equal(rej.ok, true, rej.error)
+    const r2 = await determineNextState(ctx)
+    assert.equal(r2.序, 3)
+    assert.match(r2.dto.批次.建议, /重写打回章(第 3 章)/)
+  } finally {
+    await cleanup()
+  }
+})
+
+test('批次进行中:停止命中(写满)→ 序 3 建议批量过稿', async () => {
+  const { ctx, cleanup } = await gitBookCtx()
+  try {
+    await reachOutline(ctx)
+    await setBookYaml(ctx, '连写批次大小', '1')
+    await stage3(ctx)
+    const r = await determineNextState(ctx)
+    assert.equal(r.序, 3, JSON.stringify(r))
+    assert.equal(r.dto.批次.停止.stop, true)
+    assert.match(r.dto.批次.建议, /批量过稿/)
+  } finally {
+    await cleanup()
+  }
+})