feat: harden v0.6.3 safety guards and migration hints

This commit is contained in:
Ma
2026-03-28 21:29:38 +08:00
parent 4c0ffed7ff
commit eba4a54ae4
13 changed files with 787 additions and 48 deletions
@@ -0,0 +1,171 @@
# v0.6.3 Hardening Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Close the remaining 0.6.3 safety gaps around agent tool misuse, final-content paragraph checks, duplicate chapter titles, migration hints, and rewrite regression coverage.
**Architecture:** Keep the current v2 pipeline, but tighten the remaining soft spots at the edges. Add deterministic guards in the agent dispatch layer, run final-content validation at the last safe point before persist, surface migration status in user-facing CLI flows, and add targeted regressions for the exact user-visible failures.
**Tech Stack:** TypeScript, Vitest, Commander CLI
---
### Task 1: Guard the remaining agent path
**Files:**
- Modify: `packages/core/src/__tests__/pipeline-agent.test.ts`
- Modify: `packages/core/src/pipeline/agent.ts`
**Step 1: Write the failing tests**
Add tests that prove:
- `write_full_pipeline` is rejected when the chapter sequence is inconsistent
- `write_truth_file` cannot use `current_state.md` to mutate progress
**Step 2: Run test to verify it fails**
Run:
```bash
pnpm vitest run src/__tests__/pipeline-agent.test.ts
```
**Step 3: Write minimal implementation**
Reuse the same guard logic currently applied to `write_draft` and block `write_full_pipeline` before it calls `writeNextChapter()`.
**Step 4: Run test to verify it passes**
Run:
```bash
pnpm vitest run src/__tests__/pipeline-agent.test.ts
```
**Step 5: Commit**
```bash
git add packages/core/src/pipeline/agent.ts packages/core/src/__tests__/pipeline-agent.test.ts
git commit -m "fix: guard write_full_pipeline in agent dispatch"
```
### Task 2: Validate final persisted chapter shape
**Files:**
- Modify: `packages/core/src/__tests__/post-write-validator.test.ts`
- Modify: `packages/core/src/__tests__/pipeline-runner.test.ts`
- Modify: `packages/core/src/agents/post-write-validator.ts`
- Modify: `packages/core/src/pipeline/runner.ts`
**Step 1: Write the failing tests**
Add tests that prove:
- final persisted content can trigger `段落过碎` / `连续短段`
- duplicate titles are handled as a real final-stage action, not a passive warning
**Step 2: Run tests to verify they fail**
Run:
```bash
pnpm vitest run src/__tests__/post-write-validator.test.ts src/__tests__/pipeline-runner.test.ts
```
**Step 3: Write minimal implementation**
Export a reusable final paragraph-shape detector and run it on `finalContent` before persist. Upgrade duplicate-title handling from a passive warning to an explicit final-stage resolution path.
**Step 4: Run tests to verify they pass**
Run:
```bash
pnpm vitest run src/__tests__/post-write-validator.test.ts src/__tests__/pipeline-runner.test.ts
```
**Step 5: Commit**
```bash
git add packages/core/src/agents/post-write-validator.ts packages/core/src/pipeline/runner.ts packages/core/src/__tests__/post-write-validator.test.ts packages/core/src/__tests__/pipeline-runner.test.ts
git commit -m "fix: harden final content validation and title handling"
```
### Task 3: Surface migration status in user-facing CLI flows
**Files:**
- Modify: `packages/cli/src/__tests__/cli-integration.test.ts`
- Modify: `packages/cli/src/commands/status.ts`
- Modify: `packages/cli/src/commands/write.ts`
- Modify: `packages/cli/src/utils.ts`
**Step 1: Write the failing tests**
Add tests that prove:
- `inkos status` shows a migration hint for legacy books
- `inkos write next` warns before auto-migrating a legacy book
**Step 2: Run tests to verify they fail**
Run:
```bash
pnpm vitest run src/__tests__/cli-integration.test.ts
```
**Step 3: Write minimal implementation**
Add a shared CLI helper that detects legacy pre-v0.6 books and emits a clear hint in `status` and `write next`.
**Step 4: Run tests to verify they pass**
Run:
```bash
pnpm vitest run src/__tests__/cli-integration.test.ts
```
**Step 5: Commit**
```bash
git add packages/cli/src/utils.ts packages/cli/src/commands/status.ts packages/cli/src/commands/write.ts packages/cli/src/__tests__/cli-integration.test.ts
git commit -m "feat: show migration hints in status and write flows"
```
### Task 4: Verify rewrite from user path
**Files:**
- Modify: `packages/cli/src/__tests__/cli-integration.test.ts`
**Step 1: Write the failing test**
Add a CLI-level regression covering:
- create 3 chapters
- rewrite chapter 2
- assert next chapter number is 2 again
**Step 2: Run test to verify it fails**
Run:
```bash
pnpm vitest run src/__tests__/cli-integration.test.ts
```
**Step 3: Write minimal implementation**
Prefer no production change unless the new CLI regression exposes a real gap.
**Step 4: Run tests to verify it passes**
Run:
```bash
pnpm vitest run src/__tests__/cli-integration.test.ts
```
**Step 5: Commit**
```bash
git add packages/cli/src/__tests__/cli-integration.test.ts
git commit -m "test: cover rewrite flow from CLI path"
```
@@ -4,6 +4,7 @@ import { basename, dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { execFileSync } from "node:child_process";
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { StateManager } from "@actalk/inkos-core";
const testDir = dirname(fileURLToPath(import.meta.url));
const cliDir = resolve(testDir, "..", "..");
@@ -40,6 +41,13 @@ function runStderr(args: string[], options?: { env?: Record<string, string> }):
}
}
const failingLlmEnv = {
INKOS_LLM_PROVIDER: "openai",
INKOS_LLM_BASE_URL: "http://127.0.0.1:9/v1",
INKOS_LLM_MODEL: "test-model",
INKOS_LLM_API_KEY: "test-key",
};
describe("CLI integration", () => {
beforeAll(async () => {
projectDir = await mkdtemp(join(tmpdir(), "inkos-cli-test-"));
@@ -278,6 +286,34 @@ describe("CLI integration", () => {
expect(output).toContain('Ch.1 "A Quiet Sky" | 7 words | ready-for-review');
expect(output).not.toContain("7字");
});
it("shows a migration hint for legacy pre-v0.6 books", async () => {
const bookDir = join(projectDir, "books", "legacy-status-hint");
const storyDir = join(bookDir, "story");
await mkdir(join(bookDir, "chapters"), { recursive: true });
await mkdir(storyDir, { recursive: true });
await writeFile(
join(bookDir, "book.json"),
JSON.stringify({
id: "legacy-status-hint",
title: "Legacy Status Hint",
platform: "other",
genre: "other",
status: "active",
targetChapters: 10,
chapterWordCount: 2200,
createdAt: "2026-03-22T00:00:00.000Z",
updatedAt: "2026-03-22T00:00:00.000Z",
}, null, 2),
"utf-8",
);
await writeFile(join(bookDir, "chapters", "index.json"), "[]", "utf-8");
await writeFile(join(storyDir, "current_state.md"), "# Current State\n\nLegacy state.\n", "utf-8");
await writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n\n", "utf-8");
const output = run(["status", "legacy-status-hint"]);
expect(output).toContain("legacy format");
});
});
describe("inkos doctor", () => {
@@ -345,6 +381,133 @@ describe("CLI integration", () => {
await writeFile(envPath, originalEnv, "utf-8");
}
});
it("reports legacy books in the version migration check", async () => {
const bookDir = join(projectDir, "books", "legacy-doctor-hint");
const storyDir = join(bookDir, "story");
await mkdir(join(bookDir, "chapters"), { recursive: true });
await mkdir(storyDir, { recursive: true });
await writeFile(
join(bookDir, "book.json"),
JSON.stringify({
id: "legacy-doctor-hint",
title: "Legacy Doctor Hint",
platform: "other",
genre: "other",
status: "active",
targetChapters: 10,
chapterWordCount: 2200,
createdAt: "2026-03-22T00:00:00.000Z",
updatedAt: "2026-03-22T00:00:00.000Z",
}, null, 2),
"utf-8",
);
await writeFile(join(bookDir, "chapters", "index.json"), "[]", "utf-8");
await writeFile(join(storyDir, "current_state.md"), "# Current State\n\nLegacy state.\n", "utf-8");
await writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n\n", "utf-8");
const { stdout } = runStderr(["doctor"]);
expect(stdout).toContain("Version Migration");
expect(stdout).toContain("legacy format");
});
});
describe("inkos write", () => {
it("warns before writing when the target book still uses legacy format", async () => {
const bookDir = join(projectDir, "books", "legacy-write-hint");
const storyDir = join(bookDir, "story");
await mkdir(join(bookDir, "chapters"), { recursive: true });
await mkdir(storyDir, { recursive: true });
await writeFile(
join(bookDir, "book.json"),
JSON.stringify({
id: "legacy-write-hint",
title: "Legacy Write Hint",
platform: "other",
genre: "other",
status: "active",
targetChapters: 10,
chapterWordCount: 2200,
createdAt: "2026-03-22T00:00:00.000Z",
updatedAt: "2026-03-22T00:00:00.000Z",
}, null, 2),
"utf-8",
);
await writeFile(join(bookDir, "chapters", "index.json"), "[]", "utf-8");
await writeFile(join(storyDir, "current_state.md"), "# Current State\n\nLegacy state.\n", "utf-8");
await writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n\n", "utf-8");
const { stdout, stderr } = runStderr(["write", "next", "legacy-write-hint"], {
env: failingLlmEnv,
});
expect(`${stdout}\n${stderr}`).toContain("legacy format");
});
it("keeps next chapter at 2 after rewrite 2 trims later chapters, even if regeneration fails", async () => {
const state = new StateManager(projectDir);
const bookId = "rewrite-cli";
const bookDir = join(projectDir, "books", bookId);
const storyDir = join(bookDir, "story");
const chaptersDir = join(bookDir, "chapters");
const stateDir = join(storyDir, "state");
await mkdir(chaptersDir, { recursive: true });
await mkdir(stateDir, { recursive: true });
await writeFile(
join(bookDir, "book.json"),
JSON.stringify({
id: bookId,
title: "Rewrite CLI",
platform: "other",
genre: "other",
status: "active",
targetChapters: 10,
chapterWordCount: 2200,
createdAt: "2026-03-22T00:00:00.000Z",
updatedAt: "2026-03-22T00:00:00.000Z",
}, null, 2),
"utf-8",
);
await writeFile(join(storyDir, "current_state.md"), "State at ch1", "utf-8");
await writeFile(join(storyDir, "pending_hooks.md"), "Hooks at ch1", "utf-8");
await writeFile(join(chaptersDir, "0001_ch1.md"), "# Chapter 1\n\nContent 1", "utf-8");
await writeFile(join(chaptersDir, "0002_ch2.md"), "# Chapter 2\n\nContent 2", "utf-8");
await writeFile(join(chaptersDir, "0003_ch3.md"), "# Chapter 3\n\nContent 3", "utf-8");
await writeFile(
join(chaptersDir, "index.json"),
JSON.stringify([
{ number: 1, title: "Ch1", status: "approved", wordCount: 100, createdAt: "", updatedAt: "", auditIssues: [], lengthWarnings: [] },
{ number: 2, title: "Ch2", status: "approved", wordCount: 100, createdAt: "", updatedAt: "", auditIssues: [], lengthWarnings: [] },
{ number: 3, title: "Ch3", status: "approved", wordCount: 100, createdAt: "", updatedAt: "", auditIssues: [], lengthWarnings: [] },
], null, 2),
"utf-8",
);
await state.snapshotState(bookId, 1);
await writeFile(join(storyDir, "current_state.md"), "State at ch3", "utf-8");
await writeFile(join(stateDir, "manifest.json"), JSON.stringify({
schemaVersion: 2,
language: "en",
lastAppliedChapter: 4,
projectionVersion: 1,
migrationWarnings: [],
}, null, 2), "utf-8");
await writeFile(join(stateDir, "current_state.json"), JSON.stringify({
chapter: 3,
facts: [],
}, null, 2), "utf-8");
const { exitCode, stdout, stderr } = runStderr(["write", "rewrite", bookId, "2", "--force"], {
env: failingLlmEnv,
});
expect(exitCode).not.toBe(0);
expect(`${stdout}\n${stderr}`).toContain("Regenerating chapter 2");
const next = await state.getNextChapterNumber(bookId);
expect(next).toBe(2);
await expect(readFile(join(storyDir, "current_state.md"), "utf-8")).resolves.toBe("State at ch1");
});
});
describe("inkos analytics", () => {
+6 -1
View File
@@ -1,6 +1,6 @@
import { Command } from "commander";
import { StateManager, formatLengthCount, readGenreProfile, resolveLengthCountingMode } from "@actalk/inkos-core";
import { findProjectRoot, log, logError } from "../utils.js";
import { findProjectRoot, getLegacyMigrationHint, log, logError } from "../utils.js";
export const statusCommand = new Command("status")
.description("Show project status")
@@ -32,6 +32,7 @@ export const statusCommand = new Command("status")
for (const id of bookIds) {
const book = await state.loadBookConfig(id);
const index = await state.loadChapterIndex(id);
const migrationHint = await getLegacyMigrationHint(root, id);
const nextChapter = await state.getNextChapterNumber(id);
const { profile: genreProfile } = await readGenreProfile(root, book.genre);
const countingMode = resolveLengthCountingMode(book.language ?? genreProfile.language);
@@ -59,6 +60,7 @@ export const statusCommand = new Command("status")
approved,
pending,
failed,
...(migrationHint ? { migrationHint } : {}),
...(opts.chapters ? {
chapterList: index.map((ch) => ({
number: ch.number,
@@ -77,6 +79,9 @@ export const statusCommand = new Command("status")
log(` Chapters: ${nextChapter - 1} / ${book.targetChapters}`);
log(` Words: ${totalWords.toLocaleString()} (avg ${avgWords}/ch)`);
log(` Approved: ${approved} | Pending: ${pending} | Failed: ${failed}`);
if (migrationHint) {
log(` Migration: ${migrationHint}`);
}
if (opts.chapters && index.length > 0) {
log("");
+11 -3
View File
@@ -3,7 +3,7 @@ import { PipelineRunner, StateManager } from "@actalk/inkos-core";
import { readdir, unlink } from "node:fs/promises";
import { join } from "node:path";
import { createInterface } from "node:readline";
import { loadConfig, buildPipelineConfig, findProjectRoot, resolveContext, resolveBookId, log, logError } from "../utils.js";
import { loadConfig, buildPipelineConfig, findProjectRoot, getLegacyMigrationHint, resolveContext, resolveBookId, log, logError } from "../utils.js";
import { formatWriteNextComplete, formatWriteNextProgress, formatWriteNextResultLines, resolveCliLanguage } from "../localization.js";
export const writeCommand = new Command("write")
@@ -21,13 +21,17 @@ writeCommand
.option("-q, --quiet", "Suppress console output")
.action(async (bookIdArg: string | undefined, opts) => {
try {
const config = await loadConfig();
const root = findProjectRoot();
const bookId = await resolveBookId(bookIdArg, root);
const context = await resolveContext(opts);
const state = new StateManager(root);
const book = await state.loadBookConfig(bookId);
const language = resolveCliLanguage(book.language);
const migrationHint = await getLegacyMigrationHint(root, bookId);
if (migrationHint && !opts.json) {
log(`[migration] ${migrationHint}`);
}
const config = await loadConfig();
const pipeline = new PipelineRunner(buildPipelineConfig(config, root, { externalContext: context, quiet: opts.quiet }));
@@ -81,7 +85,6 @@ writeCommand
.option("--json", "Output JSON")
.action(async (args: ReadonlyArray<string>, opts) => {
try {
const config = await loadConfig();
const root = findProjectRoot();
let bookId: string;
@@ -113,6 +116,10 @@ writeCommand
const state = new StateManager(root);
const bookDir = state.bookDir(bookId);
const chaptersDir = join(bookDir, "chapters");
const migrationHint = await getLegacyMigrationHint(root, bookId);
if (migrationHint && !opts.json) {
log(`[migration] ${migrationHint}`);
}
// Remove existing chapter file
const files = await readdir(chaptersDir);
@@ -151,6 +158,7 @@ writeCommand
const wordCount = opts.words ? parseInt(opts.words, 10) : undefined;
const config = await loadConfig();
const pipeline = new PipelineRunner(buildPipelineConfig(config, root));
const result = await pipeline.writeNextChapter(bookId, wordCount);
+19 -2
View File
@@ -1,5 +1,5 @@
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { readFile, stat } from "node:fs/promises";
import { join, resolve } from "node:path";
import { createLLMClient, StateManager, createLogger, createStderrSink, createJsonLineSink, loadProjectConfig, GLOBAL_CONFIG_DIR, GLOBAL_ENV_PATH, type ProjectConfig, type PipelineConfig, type LogSink } from "@actalk/inkos-core";
import { formatSqliteMemorySupportWarning } from "./runtime-requirements.js";
@@ -132,3 +132,20 @@ export async function resolveBookId(
`Multiple books found: ${books.join(", ")}\nPlease specify a book-id.`,
);
}
export async function getLegacyMigrationHint(
root: string,
bookId: string,
): Promise<string | null> {
const state = new StateManager(root);
const stateDir = join(state.bookDir(bookId), "story", "state");
try {
const info = await stat(stateDir);
if (info.isDirectory()) {
return null;
}
} catch {
return `Book "${bookId}" uses legacy format (pre-v0.6). The next write will auto-migrate its state files.`;
}
return `Book "${bookId}" uses legacy format (pre-v0.6). The next write will auto-migrate its state files.`;
}
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -116,4 +116,68 @@ describe("agent pipeline tools", () => {
await expect(readFile(join(state.bookDir(bookId), "story", "current_focus.md"), "utf-8"))
.resolves.toContain("mentor fallout");
});
it("blocks write_full_pipeline when runtime progress is ahead of the chapter index", async () => {
const stateDir = join(state.bookDir(bookId), "story", "state");
await mkdir(stateDir, { recursive: true });
await state.saveChapterIndex(bookId, [{
number: 1,
title: "Existing Chapter",
status: "approved",
wordCount: 120,
createdAt: "2026-03-22T00:00:00.000Z",
updatedAt: "2026-03-22T00:00:00.000Z",
auditIssues: [],
lengthWarnings: [],
}]);
await writeFile(join(stateDir, "manifest.json"), JSON.stringify({
schemaVersion: 2,
language: "zh",
lastAppliedChapter: 3,
projectionVersion: 1,
migrationWarnings: [],
}, null, 2), "utf-8");
await writeFile(join(stateDir, "current_state.json"), JSON.stringify({
chapter: 3,
facts: [],
}, null, 2), "utf-8");
const writeNextChapter = vi.spyOn(pipeline, "writeNextChapter").mockResolvedValue({
bookId,
chapterNumber: 4,
title: "Should Not Run",
wordCount: 100,
filePath: "books/agent-book/chapters/0004_Should_Not_Run.md",
auditResult: { passed: true, issues: [], summary: "ok" },
revised: false,
status: "ready-for-review",
} as Awaited<ReturnType<typeof pipeline.writeNextChapter>>);
const result = JSON.parse(await executeAgentTool(
pipeline,
state,
config,
"write_full_pipeline",
{ bookId, count: 1 },
));
expect(result.error).toContain("write_full_pipeline");
expect(writeNextChapter).not.toHaveBeenCalled();
});
it("blocks write_truth_file from hacking chapter progress inside current_state.md", async () => {
const result = JSON.parse(await executeAgentTool(
pipeline,
state,
config,
"write_truth_file",
{
bookId,
fileName: "current_state.md",
content: "# Current State\n\n| Current Chapter | 999 |\n",
},
));
expect(result.error).toContain("章节进度");
});
});
@@ -3012,6 +3012,151 @@ describe("PipelineRunner", () => {
}
});
it("adds final paragraph fragmentation warnings from revised content before persist", async () => {
const { root, runner, state, bookId } = await createRunnerFixture();
const storyDir = join(state.bookDir(bookId), "story");
const draftBody = "林越先把门推开一条缝,再侧耳去听墙后的动静。屋里的灯没有亮,但桌角还有没散的热气,说明人刚离开不久。";
const revisedBody = [
"门开了。",
"他没进去。",
"先听了一下。",
"里面没有声响。",
"他这才抬脚。",
"屋里很冷。",
].join("\n\n");
await Promise.all([
writeFile(join(storyDir, "current_state.md"), createStateCard({
chapter: 0,
location: "Ashen ferry crossing",
protagonistState: "Lin Yue still hides the oath token.",
goal: "Find the vanished mentor.",
conflict: "The debt trail keeps narrowing.",
}), "utf-8"),
writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
]);
vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
createWriterOutput({
chapterNumber: 1,
title: "雾线",
content: draftBody,
wordCount: draftBody.length,
}),
);
vi.spyOn(ContinuityAuditor.prototype, "auditChapter")
.mockResolvedValueOnce(
createAuditResult({
passed: false,
issues: [CRITICAL_ISSUE],
summary: "needs revision",
}),
)
.mockResolvedValueOnce(
createAuditResult({
passed: true,
issues: [],
summary: "clean",
}),
);
vi.spyOn(ReviserAgent.prototype, "reviseChapter").mockResolvedValue(
createReviseOutput({
revisedContent: revisedBody,
wordCount: revisedBody.length,
updatedState: createStateCard({
chapter: 1,
location: "Ashen ferry crossing",
protagonistState: "Lin Yue still hides the oath token.",
goal: "Find the vanished mentor.",
conflict: "He steps into the empty room.",
}),
updatedHooks: "# Pending Hooks\n",
}),
);
vi.spyOn(ChapterAnalyzerAgent.prototype, "analyzeChapter").mockResolvedValue(
createAnalyzedOutput({
title: "雾线",
content: revisedBody,
wordCount: revisedBody.length,
chapterSummary: "| 1 | 雾线 | 林越 | 进入空屋 | 状态推进 | 无 | 紧绷 | 过渡 |",
}),
);
try {
const result = await runner.writeNextChapter(bookId, 120);
expect(result.auditResult.issues).toEqual(
expect.arrayContaining([
expect.objectContaining({
category: "paragraph-shape",
description: expect.stringContaining("段落被切得过碎"),
}),
expect.objectContaining({
category: "paragraph-shape",
description: expect.stringContaining("连续出现"),
}),
]),
);
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("resolves duplicate chapter titles before persist", async () => {
const { root, runner, state, bookId } = await createRunnerFixture();
const storyDir = join(state.bookDir(bookId), "story");
const chaptersDir = join(state.bookDir(bookId), "chapters");
const now = "2026-03-19T00:00:00.000Z";
await Promise.all([
writeFile(join(chaptersDir, "0001_回声.md"), "# 第1章 回声\n\n旧章节。", "utf-8"),
writeFile(join(storyDir, "current_state.md"), createStateCard({
chapter: 1,
location: "Ashen ferry crossing",
protagonistState: "Lin Yue still hides the oath token.",
goal: "Find the vanished mentor.",
conflict: "The debt trail keeps narrowing.",
}), "utf-8"),
writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
]);
await state.saveChapterIndex(bookId, [{
number: 1,
title: "回声",
status: "ready-for-review",
wordCount: 12,
createdAt: now,
updatedAt: now,
auditIssues: [],
lengthWarnings: [],
}]);
vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
createWriterOutput({
chapterNumber: 2,
title: "回声",
content: "这次的正文完全不同,只是标题碰巧重复了。",
wordCount: "这次的正文完全不同,只是标题碰巧重复了。".length,
}),
);
vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue(
createAuditResult({
passed: true,
issues: [],
summary: "clean",
}),
);
try {
const result = await runner.writeNextChapter(bookId, 120);
const index = await state.loadChapterIndex(bookId);
expect(result.title).toBe("回声(2)");
expect(index.at(-1)?.title).toBe("回声(2)");
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("defaults manual reviseDraft to spot-fix when mode is omitted", async () => {
const { root, runner, state, bookId } = await createRunnerFixture();
const storyDir = join(state.bookDir(bookId), "story");
@@ -1,6 +1,8 @@
import { describe, it, expect } from "vitest";
import {
detectDuplicateTitle,
detectParagraphLengthDrift,
detectParagraphShapeWarnings,
validatePostWrite,
type PostWriteViolation,
} from "../agents/post-write-validator.js";
@@ -217,4 +219,29 @@ describe("validatePostWrite", () => {
expect(findRule(result, "段落密度漂移")).toBeDefined();
expect(findRule(result, "段落密度漂移")?.severity).toBe("warning");
});
it("exposes paragraph shape warnings for final-stage reuse", () => {
const current = [
"他停下。",
"先看门。",
"又看窗。",
"没人说话。",
"他这才进去。",
"屋里很冷。",
].join("\n\n");
const result = detectParagraphShapeWarnings(current, "zh");
expect(findRule(result, "段落过碎")).toBeDefined();
expect(findRule(result, "连续短段")).toBeDefined();
});
it("detects duplicate chapter titles", () => {
const result = detectDuplicateTitle("回声", ["旧路", "回声"]);
expect(findRule(result, "duplicate-title")).toBeDefined();
});
it("detects near-duplicate chapter titles", () => {
const result = detectDuplicateTitle("Echo-2", ["Echo 2"]);
expect(findRule(result, "near-duplicate-title")).toBeDefined();
});
});
@@ -454,6 +454,52 @@ describe("StateManager", () => {
const next = await manager.getNextChapterNumber(rwBookId);
expect(next).toBe(2);
});
it("rewrite restore drops poisoned live structured state when the snapshot only has markdown truth files", async () => {
const rwBookId = "rewrite-book-markdown-only";
const chapDir = join(manager.bookDir(rwBookId), "chapters");
const storyDir = join(manager.bookDir(rwBookId), "story");
const stateDir = join(storyDir, "state");
await mkdir(chapDir, { recursive: true });
await mkdir(storyDir, { recursive: true });
await writeFile(join(chapDir, "0001_ch1.md"), "# Chapter 1\nContent 1", "utf-8");
await writeFile(join(chapDir, "0002_ch2.md"), "# Chapter 2\nContent 2", "utf-8");
await writeFile(join(chapDir, "0003_ch3.md"), "# Chapter 3\nContent 3", "utf-8");
const mkEntry = (n: number) => ({
number: n, title: `Ch${n}`, status: "approved" as const, wordCount: 100,
createdAt: "", updatedAt: "", auditIssues: [] as string[], lengthWarnings: [] as string[],
});
const fullIndex = [mkEntry(1), mkEntry(2), mkEntry(3)];
await manager.saveChapterIndex(rwBookId, fullIndex);
await writeFile(join(storyDir, "current_state.md"), "State at ch1", "utf-8");
await writeFile(join(storyDir, "pending_hooks.md"), "Hooks at ch1", "utf-8");
await manager.snapshotState(rwBookId, 1);
await mkdir(stateDir, { recursive: true });
await writeFile(join(stateDir, "manifest.json"), JSON.stringify({
schemaVersion: 2,
language: "en",
lastAppliedChapter: 4,
projectionVersion: 1,
migrationWarnings: [],
}, null, 2), "utf-8");
await writeFile(join(stateDir, "current_state.json"), JSON.stringify({
chapter: 3,
facts: [],
}, null, 2), "utf-8");
const trimmed = fullIndex.filter((ch) => ch.number < 2);
await manager.saveChapterIndex(rwBookId, trimmed);
const { rm } = await import("node:fs/promises");
await rm(join(chapDir, "0002_ch2.md"));
await rm(join(chapDir, "0003_ch3.md"));
await manager.restoreState(rwBookId, 1);
const next = await manager.getNextChapterNumber(rwBookId);
expect(next).toBe(2);
});
});
// -------------------------------------------------------------------------
@@ -235,7 +235,7 @@ export function validatePostWrite(
});
}
appendParagraphShapeWarnings(violations, content, "zh");
violations.push(...detectParagraphShapeWarnings(content, "zh"));
// 11. Book-level prohibitions
// Short prohibitions (2-30 chars): exact substring match
@@ -398,7 +398,7 @@ function validatePostWriteEnglish(
});
}
appendParagraphShapeWarnings(violations, content, "en");
violations.push(...detectParagraphShapeWarnings(content, "en"));
// 2.5. Multi-character scene with almost no direct exchange
const quotedLines = content.match(/"[^"]+"/g) ?? [];
@@ -494,6 +494,15 @@ function appendParagraphShapeWarnings(
}
}
export function detectParagraphShapeWarnings(
content: string,
language: "zh" | "en" = "zh",
): ReadonlyArray<PostWriteViolation> {
const violations: PostWriteViolation[] = [];
appendParagraphShapeWarnings(violations, content, language);
return violations;
}
function analyzeParagraphShape(content: string, language: "zh" | "en"): ParagraphShape {
const paragraphs = extractParagraphs(content);
const shortThreshold = language === "en" ? 120 : 35;
@@ -588,3 +597,35 @@ export function detectDuplicateTitle(
return violations;
}
export function resolveDuplicateTitle(
newTitle: string,
existingTitles: ReadonlyArray<string>,
language: "zh" | "en" = "zh",
): {
readonly title: string;
readonly issues: ReadonlyArray<PostWriteViolation>;
} {
const trimmed = newTitle.trim();
if (!trimmed) {
return { title: newTitle, issues: [] };
}
const issues = detectDuplicateTitle(trimmed, existingTitles);
if (issues.length === 0) {
return { title: trimmed, issues: [] };
}
let counter = 2;
while (counter < 100) {
const candidate = language === "en"
? `${trimmed} (${counter})`
: `${trimmed}(${counter})`;
if (detectDuplicateTitle(candidate, existingTitles).length === 0) {
return { title: candidate, issues };
}
counter++;
}
return { title: trimmed, issues };
}
+37 -11
View File
@@ -370,12 +370,10 @@ export async function executeAgentTool(
}
case "write_draft": {
// Guard: write_draft only writes the next sequential chapter
const bookId = args.bookId as string;
const nextNum = await state.getNextChapterNumber(bookId);
const index = await state.loadChapterIndex(bookId);
if (index.length > 0 && index[index.length - 1]!.number !== nextNum - 1) {
return JSON.stringify({ error: `write_draft 只能续写下一章(第${nextNum}章)。如果中间有空缺,请先用 get_book_status 确认状态。` });
const writeGuardError = await getSequentialWriteGuardError(state, bookId, "write_draft");
if (writeGuardError) {
return JSON.stringify({ error: writeGuardError });
}
const result = await pipeline.writeDraft(
bookId,
@@ -494,10 +492,15 @@ export async function executeAgentTool(
}
case "write_full_pipeline": {
const bookId = args.bookId as string;
const writeGuardError = await getSequentialWriteGuardError(state, bookId, "write_full_pipeline");
if (writeGuardError) {
return JSON.stringify({ error: writeGuardError });
}
const count = (args.count as number) ?? 1;
const results = [];
for (let i = 0; i < count; i++) {
const result = await pipeline.writeNextChapter(args.bookId as string);
const result = await pipeline.writeNextChapter(bookId);
results.push(result);
}
return JSON.stringify(results);
@@ -573,11 +576,8 @@ export async function executeAgentTool(
}
// Guard: block chapter progress manipulation via current_state.md
if (fileName === "current_state.md") {
const progressPatterns = /(?:lastAppliedChapter|chapter.*:\s*\d+|当前章.*[::]\s*\d+|进度.*[::]\s*\d+)/i;
if (progressPatterns.test(content)) {
return JSON.stringify({ error: "不允许通过 write_truth_file 修改 current_state.md 中的章节进度。章节进度由系统自动管理。" });
}
if (fileName === "current_state.md" && containsProgressManipulation(content)) {
return JSON.stringify({ error: "不允许通过 write_truth_file 修改 current_state.md 中的章节进度。章节进度由系统自动管理。" });
}
const { writeFile, mkdir } = await import("node:fs/promises");
@@ -610,5 +610,31 @@ async function executeTool(
return executeAgentTool(pipeline, state, config, name, args);
}
async function getSequentialWriteGuardError(
state: import("../state/manager.js").StateManager,
bookId: string,
toolName: "write_draft" | "write_full_pipeline",
): Promise<string | null> {
const nextNum = await state.getNextChapterNumber(bookId);
const index = await state.loadChapterIndex(bookId);
if (index.length === 0) return null;
const lastIndexedChapter = index[index.length - 1]!.number;
if (lastIndexedChapter === nextNum - 1) return null;
return `${toolName} 只能续写下一章(当前应写第${nextNum}章)。检测到章节索引与运行时进度不一致,请先用 get_book_status 确认状态。`;
}
function containsProgressManipulation(content: string): boolean {
const patterns = [
/\blastAppliedChapter\b/i,
/\|\s*Current Chapter\s*\|\s*\d+\s*\|/i,
/\|\s*当前章(?:节)?\s*\|\s*\d+\s*\|/,
/\bCurrent Chapter\b\s*[::]\s*\d+/i,
/当前章(?:节)?\s*[::]\s*\d+/,
/\bprogress\b\s*[::]\s*\d+/i,
/进度\s*[::]\s*\d+/,
];
return patterns.some((pattern) => pattern.test(content));
}
/** Export tool definitions so external systems can reference them. */
export { TOOLS as AGENT_TOOLS };
+47 -26
View File
@@ -1100,16 +1100,53 @@ export class PipelineRunner {
// 4. Save the final chapter and truth files from a single persistence source
this.logStage(stageLanguage, { zh: "落盘最终章节", en: "persisting final chapter" });
this.logStage(stageLanguage, { zh: "生成最终真相文件", en: "rebuilding final truth files" });
const persistenceOutput = await this.buildPersistenceOutput(
const chapterIndexBeforePersist = await this.state.loadChapterIndex(bookId);
const { resolveDuplicateTitle } = await import("../agents/post-write-validator.js");
const initialTitleResolution = resolveDuplicateTitle(
output.title,
chapterIndexBeforePersist.map((chapter) => chapter.title),
pipelineLang,
);
let persistenceOutput = await this.buildPersistenceOutput(
bookId,
book,
bookDir,
chapterNumber,
output,
initialTitleResolution.title === output.title
? output
: { ...output, title: initialTitleResolution.title },
finalContent,
lengthSpec.countingMode,
reducedControlInput,
);
const finalTitleResolution = resolveDuplicateTitle(
persistenceOutput.title,
chapterIndexBeforePersist.map((chapter) => chapter.title),
pipelineLang,
);
if (finalTitleResolution.title !== persistenceOutput.title) {
persistenceOutput = {
...persistenceOutput,
title: finalTitleResolution.title,
};
}
if (persistenceOutput.title !== output.title) {
const description = pipelineLang === "en"
? `Duplicate chapter title "${output.title}" was auto-renamed to "${persistenceOutput.title}".`
: `章节标题"${output.title}"与已有标题重复,已自动改为"${persistenceOutput.title}"。`;
this.config.logger?.warn(`[title] ${description}`);
auditResult = {
...auditResult,
issues: [...auditResult.issues, {
severity: "warning",
category: "title-dedup",
description,
suggestion: pipelineLang === "en"
? "If the auto-renamed title is weak, revise the chapter title manually."
: "如果自动改名不理想,可以在后续手动修订章节标题。",
}],
};
}
const longSpanFatigue = await analyzeLongSpanFatigue({
bookDir,
chapterNumber,
@@ -1178,7 +1215,10 @@ export class PipelineRunner {
// 4.2 Final paragraph shape check on persisted content (post-normalize, post-revise)
{
const { detectParagraphLengthDrift } = await import("../agents/post-write-validator.js");
const {
detectParagraphLengthDrift,
detectParagraphShapeWarnings,
} = await import("../agents/post-write-validator.js");
const chapDir = join(bookDir, "chapters");
const recentFiles = (await readdir(chapDir).catch(() => [] as string[]))
.filter((f) => f.endsWith(".md") && /^\d{4}/.test(f))
@@ -1187,7 +1227,10 @@ export class PipelineRunner {
const recentContent = (await Promise.all(
recentFiles.map((f) => readFile(join(chapDir, f), "utf-8").catch(() => "")),
)).join("\n\n");
const paragraphIssues = detectParagraphLengthDrift(finalContent, recentContent, pipelineLang);
const paragraphIssues = [
...detectParagraphShapeWarnings(finalContent, pipelineLang),
...detectParagraphLengthDrift(finalContent, recentContent, pipelineLang),
];
if (paragraphIssues.length > 0) {
for (const issue of paragraphIssues) {
this.config.logger?.warn(`[paragraph] ${issue.description}`);
@@ -1198,28 +1241,6 @@ export class PipelineRunner {
severity: v.severity as "warning",
category: "paragraph-shape",
description: v.description,
suggestion: v.description,
}))],
};
}
}
// 4.3 Title dedup check
{
const { detectDuplicateTitle } = await import("../agents/post-write-validator.js");
const chapterIndex = await this.state.loadChapterIndex(bookId);
const existingTitles = chapterIndex.map((ch) => ch.title);
const titleIssues = detectDuplicateTitle(persistenceOutput.title, existingTitles);
if (titleIssues.length > 0) {
for (const issue of titleIssues) {
this.config.logger?.warn(`[title] ${issue.description}`);
}
auditResult = {
...auditResult,
issues: [...auditResult.issues, ...titleIssues.map((v) => ({
severity: v.severity as "warning",
category: "title-dedup",
description: v.description,
suggestion: v.suggestion,
}))],
};
+7 -2
View File
@@ -1,4 +1,4 @@
import { readFile, writeFile, mkdir, readdir, stat, unlink, open } from "node:fs/promises";
import { readFile, writeFile, mkdir, readdir, rm, stat, unlink, open } from "node:fs/promises";
import { join } from "node:path";
import type { BookConfig } from "../models/book.js";
import type { ChapterMeta } from "../models/chapter.js";
@@ -303,11 +303,13 @@ export class StateManager {
}),
);
const stateDir = this.stateDir(bookId);
let restoredStructuredState = false;
try {
const snapshotStateDir = join(snapshotDir, "state");
const stateFiles = await readdir(snapshotStateDir);
if (stateFiles.length > 0) {
const stateDir = this.stateDir(bookId);
restoredStructuredState = true;
await mkdir(stateDir, { recursive: true });
await Promise.all(
stateFiles.map(async (fileName) => {
@@ -319,6 +321,9 @@ export class StateManager {
} catch {
// snapshot structured state missing — skip
}
if (!restoredStructuredState) {
await rm(stateDir, { recursive: true, force: true });
}
return true;
} catch {