Răsfoiți Sursa

feat(v7): M3 P1——状态机单入口路由 + 序0-6 检测 + next CLI

- determineNextState(ctx):先 checkGitHealth,再序 0-6 命中即停判定下一步,
  返回 {序, state, needsAI, message, gitHealth, dto};只路由不判业务不调 AI
- detectors:解析失败(0)/无书(1)/手改(2,git status)/工作区未完成(3)/卷末(4)/体检周期(5)/其余(6)
- needsAI 标注:0/1/4/6 需 AI(交 M4),2/3/5 纯脚本
- git.status 加 core.quotePath=false(中文路径不转义,手改检测可匹配)
- next CLI(「继续」单入口)打印 git 处理摘要 + 当前状态
- 路由测试 8 例:命中各序 + 命中即停(手改优先于工作区草稿)
lingfengQAQ 1 zi în urmă
părinte
comite
952b8378ee

+ 19 - 0
v7/src/commands/next.js

@@ -0,0 +1,19 @@
+import { determineNextState } from '../state-machine/index.js'
+
+/**
+ * next(「继续」单入口):跑状态机判定下一步,打印中文摘要。
+ * 契约:纯返回 {ok, output?, error?}(见 design §6.2)。
+ */
+export async function run(args, options, ctx) {
+  const r = await determineNextState(ctx)
+  const lines = []
+  if (r.gitHealth.fixed.length) {
+    lines.push('【git 已自动处理】', ...r.gitHealth.fixed.map((s) => '  · ' + s))
+  }
+  if (r.gitHealth.guidance.length) {
+    lines.push('【需你留意】', ...r.gitHealth.guidance.map((s) => '  · ' + s))
+  }
+  lines.push(`【当前状态】序${r.序} ${r.state}${r.needsAI ? '(需 AI)' : ''}`)
+  lines.push(r.message)
+  return { ok: true, output: lines.join('\n') }
+}

+ 2 - 1
v7/src/finalize/git.js

@@ -55,7 +55,8 @@ export function createGit(repoPath) {
       }
     },
     async status() {
-      const { stdout } = await run(['status', '--porcelain'])
+      // core.quotePath=false:中文路径不转义成 \xxx,便于按前缀匹配
+      const { stdout } = await run(['-c', 'core.quotePath=false', 'status', '--porcelain'])
       return stdout
     },
     /** 是否处于未完成合并(存在 MERGE_HEAD) */

+ 77 - 0
v7/src/state-machine/detectors.js

@@ -0,0 +1,77 @@
+import { promises as fs } from 'node:fs'
+import path from 'node:path'
+import { parseFrontMatter } from '../storage/parsers/front-matter.js'
+import { createGit } from '../finalize/git.js'
+
+/** 序0:扫描源文件 front matter 解析失败 */
+export async function detectParseFailures(repoPath) {
+  const subs = [
+    '定稿/正文',
+    '大纲/伏笔',
+    '大纲/悬念',
+    '大纲/感情线',
+    '定稿/设定/角色',
+    '定稿/设定/信息差',
+  ]
+  const failures = []
+  for (const sub of subs) {
+    const base = path.join(repoPath, sub)
+    let files
+    try {
+      files = await fs.readdir(base)
+    } catch {
+      continue
+    }
+    for (const f of files) {
+      if (!f.endsWith('.md')) continue
+      const parsed = parseFrontMatter(await fs.readFile(path.join(base, f), 'utf8'))
+      if (!parsed.ok) failures.push({ file: `${sub}/${f}`, error: parsed.error })
+    }
+  }
+  return failures
+}
+
+/** 序1:无 book.yaml(当前目录还没有书) */
+export async function bookMissing(repoPath) {
+  try {
+    await fs.access(path.join(repoPath, 'book.yaml'))
+    return false
+  } catch {
+    return true
+  }
+}
+
+/** 序2:定稿/大纲 有未登记手改(git 工作树有未提交改动) */
+export async function hasManualEdits(repoPath) {
+  try {
+    const status = await createGit(repoPath).status()
+    return status
+      .split('\n')
+      .filter(Boolean)
+      .some((l) => {
+        const p = l.slice(3)
+        return p.startsWith('定稿') || p.startsWith('大纲')
+      })
+  } catch {
+    return false
+  }
+}
+
+/** 序3:工作区有未完成流程(草稿/审稿/待定稿批次) */
+export async function hasUnfinishedWork(repoPath) {
+  const ws = path.join(repoPath, '工作区')
+  let files
+  try {
+    files = await fs.readdir(ws)
+  } catch {
+    return false
+  }
+  if (files.some((f) => f.startsWith('草稿') || f === '审稿.md')) return true
+  try {
+    const batch = await fs.readdir(path.join(ws, '待定稿'))
+    if (batch.length) return true
+  } catch {
+    // 无待定稿
+  }
+  return false
+}

+ 63 - 3
v7/src/state-machine/index.js

@@ -1,3 +1,63 @@
-// 状态机:单入口,启动序列与 7 个态的编排(只编排,不做业务判断)。
-// 占位——真实实现见 M3。
-export {}
+import { checkGitHealth } from './git-health.js'
+import { BookConfigReader } from '../storage/adapters/BookConfigReader.js'
+import * as d from './detectors.js'
+
+/**
+ * 状态机单入口(spec §10):先跑 git 健康检查,再按序 0-6 命中即停判定下一步。
+ * 只路由、不判业务、不调 AI——AI 态返回 needsAI=true + dto 交 M4。
+ * @param {{repoPath: string, cache: object}} ctx
+ * @returns {Promise<{ok, gitHealth, 序, state, needsAI, message, dto}>}
+ */
+export async function determineNextState(ctx) {
+  const { repoPath, cache } = ctx
+  const gitHealth = await checkGitHealth(ctx)
+
+  // 序0 修复确认(检测=脚本,提议=AI)
+  const failures = await d.detectParseFailures(repoPath)
+  if (failures.length) {
+    return mk(0, 'repair-confirm', true, `检测到 ${failures.length} 个源文件解析失败,需逐个修复确认。`, gitHealth, { failures })
+  }
+
+  // 序1 建书引导
+  if (await d.bookMissing(repoPath)) {
+    return mk(1, 'create-book', true, '当前目录还没有书,进入建书引导。', gitHealth, {})
+  }
+
+  // 序2 手改补登
+  if (await d.hasManualEdits(repoPath)) {
+    return mk(2, 'relink-manual-edits', false, '定稿/大纲 有未登记的手改,建议补登(fix)。', gitHealth, {})
+  }
+
+  // 序3 断点续跑
+  if (await d.hasUnfinishedWork(repoPath)) {
+    return mk(3, 'resume', false, '工作区有未完成的流程,从中断处继续。', gitHealth, {})
+  }
+
+  // 序4/5/6 需章号信息
+  const maxRow = await cache.query('SELECT MAX(chapter_num) AS m FROM chapters')
+  const maxChapter = maxRow[0]?.m || 0
+  const config = await new BookConfigReader(repoPath).read()
+  const 卷规模 = (config.ok && config.data.卷规模) || 40
+  const 体检周期 = (config.ok && config.data.体检周期) || 50
+
+  // 序4 卷复盘(卷末章;对谈=AI)
+  if (maxChapter > 0 && maxChapter % 卷规模 === 0) {
+    return mk(4, 'volume-review', true, `第 ${maxChapter} 章是卷末,进入卷复盘。`, gitHealth, {
+      卷: Math.floor(maxChapter / 卷规模),
+    })
+  }
+
+  // 序5 体检(脚本项;指纹推 M3+)
+  if (maxChapter > 0 && maxChapter % 体检周期 === 0) {
+    return mk(5, 'health-check', false, `已到体检周期(第 ${maxChapter} 章),进入体检。`, gitHealth, {})
+  }
+
+  // 序6 起草新章细纲(近况=脚本,拟提案=AI)
+  return mk(6, 'draft-outline', true, `起草第 ${maxChapter + 1} 章细纲。`, gitHealth, {
+    nextChapter: maxChapter + 1,
+  })
+}
+
+function mk(序, state, needsAI, message, gitHealth, dto) {
+  return { ok: true, 序, state, needsAI, message, gitHealth, dto }
+}

+ 159 - 0
v7/test/state-machine/router.test.js

@@ -0,0 +1,159 @@
+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 { CacheManager } from '../../src/cache/index.js'
+import { determineNextState } from '../../src/state-machine/index.js'
+
+const execFileAsync = promisify(execFile)
+
+// 造 git 书仓库 + 缓存。files = {相对路径: 内容};committed=true 时初始全部提交
+async function makeGitBook(files, { commit = true } = {}) {
+  const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wnw-sm-'))
+  const git = (a) => execFileAsync('git', a, { cwd: root })
+  await git(['init', '-q'])
+  await git(['config', 'user.email', 't@example.com'])
+  await git(['config', 'user.name', 'test'])
+  await fs.writeFile(path.join(root, '.gitignore'), '.cache/\n工作区/\n', 'utf8')
+  for (const [rel, content] of Object.entries(files)) {
+    const full = path.join(root, rel)
+    await fs.mkdir(path.dirname(full), { recursive: true })
+    await fs.writeFile(full, content, 'utf8')
+  }
+  if (commit) {
+    await git(['add', '-A'])
+    await git(['commit', '-q', '-m', 'init'])
+  }
+  const dbDir = await fs.mkdtemp(path.join(os.tmpdir(), 'wnw-sm-db-'))
+  const cache = new CacheManager(path.join(dbDir, 'index.db'))
+  await cache.ensureReady(root)
+  return {
+    root,
+    git,
+    ctx: { repoPath: root, cache },
+    cleanup: async () => {
+      await cache.close()
+      await fs.rm(root, { recursive: true, force: true })
+      await fs.rm(dbDir, { recursive: true, force: true })
+    },
+  }
+}
+
+const ch = (n, vol = 1, pos = '推进') =>
+  `---\n章号: ${n}\n标题: 第${n}章\n卷: ${vol}\n字数: 100\n章定位: ${pos}\n钩子: 危机钩-强\n情绪定位: 铺垫\n---\n正文。`
+
+const healthyBook = (extra = {}) => ({
+  'book.yaml': 'spec_version: "7.0"\n书名: 测\n卷规模: 40\n体检周期: 50\n',
+  '大纲/总纲.md': '# 总纲\n## 结局\nx',
+  '定稿/正文/0001-第1章.md': ch(1),
+  ...extra,
+})
+
+test('序1:无 book.yaml → 建书引导', async () => {
+  const { ctx, cleanup } = await makeGitBook({ '大纲/占位.md': 'x' })
+  try {
+    const r = await determineNextState(ctx)
+    assert.equal(r.序, 1)
+    assert.equal(r.state, 'create-book')
+  } finally {
+    await cleanup()
+  }
+})
+
+test('序6:健康书、无异常 → 起草新章细纲', async () => {
+  const { ctx, cleanup } = await makeGitBook(healthyBook())
+  try {
+    const r = await determineNextState(ctx)
+    assert.equal(r.序, 6, `实际:${JSON.stringify(r)}`)
+    assert.equal(r.state, 'draft-outline')
+  } finally {
+    await cleanup()
+  }
+})
+
+test('序0:源文件解析失败 → 修复确认', async () => {
+  const { ctx, cleanup } = await makeGitBook(
+    healthyBook({ '定稿/正文/0002-坏章.md': '---\n章号: 2\n标题: [未闭合\n卷: : :\n---\n正文' })
+  )
+  try {
+    const r = await determineNextState(ctx)
+    assert.equal(r.序, 0)
+    assert.equal(r.state, 'repair-confirm')
+  } finally {
+    await cleanup()
+  }
+})
+
+test('序2:定稿有未登记手改 → 提议补登', async () => {
+  const { ctx, root, cleanup } = await makeGitBook(healthyBook())
+  try {
+    // 提交后手改一个已跟踪文件(不提交)
+    await fs.writeFile(path.join(root, '定稿/正文/0001-第1章.md'), ch(1) + '\n手改了一句。', 'utf8')
+    const r = await determineNextState(ctx)
+    assert.equal(r.序, 2)
+    assert.equal(r.state, 'relink-manual-edits')
+  } finally {
+    await cleanup()
+  }
+})
+
+test('序3:工作区有未完成草稿 → 断点续跑', async () => {
+  const { ctx, root, cleanup } = await makeGitBook(healthyBook())
+  try {
+    await fs.mkdir(path.join(root, '工作区'), { recursive: true })
+    await fs.writeFile(path.join(root, '工作区/草稿-A.md'), '半成品草稿', 'utf8')
+    const r = await determineNextState(ctx)
+    assert.equal(r.序, 3)
+    assert.equal(r.state, 'resume')
+  } finally {
+    await cleanup()
+  }
+})
+
+test('序4:卷末章 → 卷复盘', async () => {
+  const { ctx, cleanup } = await makeGitBook({
+    'book.yaml': 'spec_version: "7.0"\n书名: 测\n卷规模: 2\n体检周期: 50\n',
+    '大纲/总纲.md': '# 总纲',
+    '定稿/正文/0001-第1章.md': ch(1),
+    '定稿/正文/0002-第2章.md': ch(2),
+  })
+  try {
+    const r = await determineNextState(ctx)
+    assert.equal(r.序, 4, `实际:${JSON.stringify(r)}`)
+    assert.equal(r.state, 'volume-review')
+  } finally {
+    await cleanup()
+  }
+})
+
+test('序5:到体检周期 → 体检', async () => {
+  const { ctx, cleanup } = await makeGitBook({
+    'book.yaml': 'spec_version: "7.0"\n书名: 测\n卷规模: 3\n体检周期: 2\n',
+    '大纲/总纲.md': '# 总纲',
+    '定稿/正文/0001-第1章.md': ch(1),
+    '定稿/正文/0002-第2章.md': ch(2),
+  })
+  try {
+    const r = await determineNextState(ctx)
+    assert.equal(r.序, 5, `实际:${JSON.stringify(r)}`)
+    assert.equal(r.state, 'health-check')
+  } finally {
+    await cleanup()
+  }
+})
+
+test('命中即停:手改(序2) + 工作区草稿(序3) 同时存在 → 先报序2', async () => {
+  const { ctx, root, cleanup } = await makeGitBook(healthyBook())
+  try {
+    await fs.writeFile(path.join(root, '定稿/正文/0001-第1章.md'), ch(1) + '\n手改。', 'utf8')
+    await fs.mkdir(path.join(root, '工作区'), { recursive: true })
+    await fs.writeFile(path.join(root, '工作区/草稿-A.md'), '草稿', 'utf8')
+    const r = await determineNextState(ctx)
+    assert.equal(r.序, 2)
+  } finally {
+    await cleanup()
+  }
+})