mirror of
https://github.com/Narcooo/inkos.git
synced 2026-09-24 15:29:47 +08:00
feat: harden v0.6.3 safety guards and migration hints
This commit is contained in:
@@ -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", () => {
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
}))],
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user