novelforge-agent 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +16 -12
  2. package/dist/src/cli/index.js +10 -2
  3. package/dist/src/core/contextBuilder.js +32 -0
  4. package/dist/src/core/projectDiscovery.js +1 -0
  5. package/dist/src/core/projectOps.js +7 -1
  6. package/dist/src/core/projectStore.js +4 -1
  7. package/dist/src/core/prompts/en-US.js +130 -3
  8. package/dist/src/core/prompts/zh-CN.js +130 -3
  9. package/dist/src/core/schemas.js +23 -0
  10. package/dist/src/core/steps/architectureExtension.js +72 -0
  11. package/dist/src/core/steps/chapterReview.js +2 -1
  12. package/dist/src/core/steps/index.js +4 -0
  13. package/dist/src/core/steps/memoryCard.js +22 -1
  14. package/dist/src/core/steps/storyBible.js +1 -1
  15. package/dist/src/core/steps/styleGuide.js +12 -0
  16. package/dist/src/core/workflow.js +2 -0
  17. package/dist/src/mcp/tools.js +36 -8
  18. package/package.json +1 -1
  19. package/src/cli/index.ts +11 -3
  20. package/src/core/contextBuilder.ts +30 -0
  21. package/src/core/projectDiscovery.ts +2 -0
  22. package/src/core/projectOps.ts +9 -1
  23. package/src/core/projectStore.ts +8 -1
  24. package/src/core/prompts/en-US.ts +132 -3
  25. package/src/core/prompts/types.ts +2 -0
  26. package/src/core/prompts/zh-CN.ts +132 -3
  27. package/src/core/schemas.ts +25 -0
  28. package/src/core/steps/architectureExtension.ts +88 -0
  29. package/src/core/steps/chapterReview.ts +2 -1
  30. package/src/core/steps/index.ts +4 -0
  31. package/src/core/steps/memoryCard.ts +23 -1
  32. package/src/core/steps/storyBible.ts +1 -1
  33. package/src/core/steps/styleGuide.ts +13 -0
  34. package/src/core/types.ts +32 -0
  35. package/src/core/workflow.ts +2 -0
  36. package/src/mcp/tools.ts +35 -8
@@ -0,0 +1,72 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { ArchitectureExtensionPayloadSchema } from '../schemas.js';
4
+ import { saveJsonFile, saveMarkdownFile } from '../projectStore.js';
5
+ import { parseJson } from './types.js';
6
+ async function readJsonArray(projectPath, relativePath) {
7
+ try {
8
+ const raw = await readFile(join(projectPath, relativePath), 'utf8');
9
+ const parsed = JSON.parse(raw);
10
+ return Array.isArray(parsed) ? parsed : [];
11
+ }
12
+ catch {
13
+ return [];
14
+ }
15
+ }
16
+ function assertContiguousExtension(state, existing, nextChapters) {
17
+ const expectedStart = state.currentChapter;
18
+ const total = state.plannedTotalChapters ?? state.targetChapters;
19
+ const existingNumbers = new Set(existing.map((chapter) => chapter.chapterNumber));
20
+ for (let index = 0; index < nextChapters.length; index += 1) {
21
+ const chapter = nextChapters[index];
22
+ const expected = expectedStart + index;
23
+ if (chapter.chapterNumber !== expected) {
24
+ throw new Error(`architecture_extension chapters must start at chapter ${expectedStart} and be contiguous; expected ${expected}, got ${chapter.chapterNumber}`);
25
+ }
26
+ if (chapter.chapterNumber > total) {
27
+ throw new Error(`architecture_extension chapter ${chapter.chapterNumber} exceeds plannedTotalChapters ${total}`);
28
+ }
29
+ if (existingNumbers.has(chapter.chapterNumber)) {
30
+ throw new Error(`architecture_extension cannot overwrite existing chapter architecture ${chapter.chapterNumber}`);
31
+ }
32
+ }
33
+ }
34
+ function mergeByKey(existing, incoming, keyOf) {
35
+ const map = new Map();
36
+ for (const item of existing)
37
+ map.set(keyOf(item), item);
38
+ for (const item of incoming ?? [])
39
+ map.set(keyOf(item), item);
40
+ return [...map.values()];
41
+ }
42
+ export const architectureExtensionHandler = async (state, content) => {
43
+ const parsed = ArchitectureExtensionPayloadSchema.parse(parseJson(content));
44
+ const existingChapters = await readJsonArray(state.projectPath, 'architecture/chapters.json');
45
+ assertContiguousExtension(state, existingChapters, parsed.chapters);
46
+ const chapters = [...existingChapters, ...parsed.chapters]
47
+ .sort((a, b) => a.chapterNumber - b.chapterNumber);
48
+ const existingVolumes = await readJsonArray(state.projectPath, 'architecture/volumes.json');
49
+ const volumes = mergeByKey(existingVolumes, parsed.volumes, (volume) => volume.id)
50
+ .sort((a, b) => a.order - b.order);
51
+ const existingPacing = await readJsonArray(state.projectPath, 'architecture/volume-pacing.json');
52
+ const volumePacing = mergeByKey(existingPacing, parsed.volumePacing, (board) => board.volumeId);
53
+ const savedPaths = [
54
+ await saveJsonFile(state.projectPath, 'architecture/chapters.json', chapters),
55
+ await saveJsonFile(state.projectPath, 'architecture/volumes.json', volumes),
56
+ ];
57
+ const hasVolumePacing = Boolean(parsed.volumePacing || existingPacing.length);
58
+ if (hasVolumePacing) {
59
+ savedPaths.push(await saveJsonFile(state.projectPath, 'architecture/volume-pacing.json', volumePacing));
60
+ }
61
+ if (parsed.fullUpdate) {
62
+ savedPaths.push(await saveMarkdownFile(state.projectPath, 'architecture/full.md', parsed.fullUpdate));
63
+ }
64
+ return {
65
+ savedPaths,
66
+ fileEntries: {
67
+ architecture: 'architecture/chapters.json',
68
+ ...(hasVolumePacing ? { volumePacing: 'architecture/volume-pacing.json' } : {}),
69
+ },
70
+ next: { kind: 'linear', nextStep: 'chapter' },
71
+ };
72
+ };
@@ -5,6 +5,7 @@ import { chapterReviewFileName } from '../fileNames.js';
5
5
  import { parseJson } from './types.js';
6
6
  export const chapterReviewHandler = async (state, content) => {
7
7
  const parsed = ChapterReviewSchema.parse(parseJson(content));
8
+ const hasFailedAcceptance = Object.values(parsed.acceptance).some((check) => check.status === 'fail');
8
9
  const target = state.pendingAction?.chapterNumber ?? parsed.chapterNumber;
9
10
  const relative = join('reviews/chapter', chapterReviewFileName(target));
10
11
  const path = await saveJsonFile(state.projectPath, relative, parsed);
@@ -15,7 +16,7 @@ export const chapterReviewHandler = async (state, content) => {
15
16
  next: { kind: 'sideTrackReturn' },
16
17
  };
17
18
  }
18
- if (parsed.status === 'clean') {
19
+ if (parsed.status === 'clean' && !hasFailedAcceptance) {
19
20
  return {
20
21
  savedPaths: [path],
21
22
  fileEntries: { [`review-chapter-${target}`]: relative },
@@ -1,3 +1,4 @@
1
+ import { architectureExtensionHandler } from './architectureExtension.js';
1
2
  import { architectureHandler } from './architecture.js';
2
3
  import { chapterHandler } from './chapter.js';
3
4
  import { chapterReviewHandler } from './chapterReview.js';
@@ -6,11 +7,14 @@ import { continuityReviewHandler } from './continuityReview.js';
6
7
  import { crossChapterReviewHandler } from './crossChapterReview.js';
7
8
  import { memoryCardHandler } from './memoryCard.js';
8
9
  import { novelMetadataHandler } from './novelMetadata.js';
10
+ import { styleGuideHandler } from './styleGuide.js';
9
11
  import { storyBibleHandler } from './storyBible.js';
10
12
  export const STEP_HANDLERS = {
11
13
  novel_metadata: novelMetadataHandler,
12
14
  story_bible: storyBibleHandler,
15
+ style_guide: styleGuideHandler,
13
16
  architecture: architectureHandler,
17
+ architecture_extension: architectureExtensionHandler,
14
18
  chapter: chapterHandler,
15
19
  memory_card: memoryCardHandler,
16
20
  continuity_review: continuityReviewHandler,
@@ -1,3 +1,4 @@
1
+ import { readFile } from 'node:fs/promises';
1
2
  import { join } from 'node:path';
2
3
  import { MemoryCardSchema } from '../schemas.js';
3
4
  import { saveJsonFile } from '../projectStore.js';
@@ -6,6 +7,19 @@ import { indexMemoryCard } from '../retrieval/index.js';
6
7
  import { ingestMemoryCardThreads } from '../threadStore.js';
7
8
  import { applyCharacterUpdates } from '../characterStore.js';
8
9
  import { parseJson } from './types.js';
10
+ async function maxPlannedChapter(projectPath) {
11
+ try {
12
+ const raw = await readFile(join(projectPath, 'architecture/chapters.json'), 'utf8');
13
+ const chapters = JSON.parse(raw);
14
+ return chapters.reduce((max, chapter) => {
15
+ const value = Number(chapter.chapterNumber);
16
+ return Number.isFinite(value) && value > max ? value : max;
17
+ }, 0);
18
+ }
19
+ catch {
20
+ return 0;
21
+ }
22
+ }
9
23
  export const memoryCardHandler = async (state, content) => {
10
24
  const parsed = MemoryCardSchema.parse(parseJson(content));
11
25
  const relative = join('memory', memoryFileName(state.currentChapter));
@@ -14,12 +28,19 @@ export const memoryCardHandler = async (state, content) => {
14
28
  await ingestMemoryCardThreads(state.projectPath, state.currentChapter, parsed.threadActions);
15
29
  await applyCharacterUpdates(state.projectPath, state.currentChapter, parsed.characterUpdates);
16
30
  const nextChapter = state.currentChapter + 1;
31
+ const plannedTotalChapters = state.plannedTotalChapters ?? state.targetChapters;
32
+ const plannedMax = await maxPlannedChapter(state.projectPath);
33
+ const nextStep = nextChapter > plannedTotalChapters
34
+ ? 'continuity_review'
35
+ : nextChapter > plannedMax
36
+ ? 'architecture_extension'
37
+ : 'chapter';
17
38
  return {
18
39
  savedPaths: [path],
19
40
  fileEntries: { [`memory-${state.currentChapter}`]: relative },
20
41
  next: {
21
42
  kind: 'linear',
22
- nextStep: nextChapter > state.targetChapters ? 'continuity_review' : 'chapter',
43
+ nextStep,
23
44
  statePatch: { currentChapter: nextChapter },
24
45
  },
25
46
  };
@@ -8,6 +8,6 @@ export const storyBibleHandler = async (state, content) => {
8
8
  return {
9
9
  savedPaths: [path],
10
10
  fileEntries: { storyBible: 'story-bible.md' },
11
- next: { kind: 'linear', nextStep: 'architecture' },
11
+ next: { kind: 'linear', nextStep: 'style_guide' },
12
12
  };
13
13
  };
@@ -0,0 +1,12 @@
1
+ import { StyleGuideSchema } from '../schemas.js';
2
+ import { saveJsonFile } from '../projectStore.js';
3
+ import { parseJson } from './types.js';
4
+ export const styleGuideHandler = async (state, content) => {
5
+ const parsed = StyleGuideSchema.parse(parseJson(content));
6
+ const path = await saveJsonFile(state.projectPath, 'style-guide.json', parsed);
7
+ return {
8
+ savedPaths: [path],
9
+ fileEntries: { styleGuide: 'style-guide.json' },
10
+ next: { kind: 'linear', nextStep: 'architecture' },
11
+ };
12
+ };
@@ -5,6 +5,8 @@ import { buildPromptForStep } from './prompts.js';
5
5
  import { loadState, saveJsonFile, saveRecoveryFile, saveState } from './projectStore.js';
6
6
  import { STEP_HANDLERS } from './steps/index.js';
7
7
  const CONTEXT_RECIPES = {
8
+ style_guide: () => ({ purpose: 'style_guide' }),
9
+ architecture_extension: (s) => ({ purpose: 'architecture_extension', chapterNumber: s.currentChapter }),
8
10
  chapter: (s) => ({ purpose: 'chapter_generation', chapterNumber: s.currentChapter }),
9
11
  memory_card: (s) => ({ purpose: 'memory_extraction', chapterNumber: s.currentChapter }),
10
12
  continuity_review: () => ({ purpose: 'continuity_review' }),
@@ -1,8 +1,28 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { z } from 'zod';
3
- import { resolve } from 'node:path';
3
+ import { readFileSync } from 'node:fs';
4
+ import { dirname, join, resolve } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
4
6
  import { amendStoryBible, assertProjectPath, buildContext, createProject, deleteChapter, forkProject, getNextStep, getProjectStatus, listProjects, listStoryBibleVersions, loadState, loadThreads, redoStep, requestSideTrack, retrieve, submitStepResult, updateThread, } from '../core/index.js';
5
- const MCP_SERVER_VERSION = '0.2.0';
7
+ function packageVersion() {
8
+ let dir = dirname(fileURLToPath(import.meta.url));
9
+ while (true) {
10
+ try {
11
+ const raw = readFileSync(join(dir, 'package.json'), 'utf8');
12
+ const parsed = JSON.parse(raw);
13
+ if (typeof parsed.version === 'string' && parsed.version)
14
+ return parsed.version;
15
+ }
16
+ catch {
17
+ // keep walking upward until the package root is found
18
+ }
19
+ const parent = dirname(dir);
20
+ if (parent === dir)
21
+ return '0.0.0';
22
+ dir = parent;
23
+ }
24
+ }
25
+ const MCP_SERVER_VERSION = packageVersion();
6
26
  function textResult(value) {
7
27
  return {
8
28
  content: [{
@@ -28,14 +48,16 @@ export function createNovelAgentServer(options) {
28
48
  prompt: z.string().min(1),
29
49
  language: z.enum(['zh-CN', 'en-US']).default('zh-CN'),
30
50
  outputDir: z.string().default('novels'),
31
- targetChapters: z.number().int().positive().default(3),
32
- }, async ({ prompt, language, outputDir, targetChapters }) => {
51
+ targetChapters: z.number().int().positive().default(5),
52
+ plannedTotalChapters: z.number().int().positive().default(12),
53
+ }, async ({ prompt, language, outputDir, targetChapters, plannedTotalChapters }) => {
33
54
  const result = await createProject({
34
55
  workspaceRoot: options.workspaceRoot,
35
56
  prompt,
36
57
  language,
37
58
  outputDir,
38
59
  targetChapters,
60
+ plannedTotalChapters,
39
61
  });
40
62
  return textResult({ state: result.state, next: await getNextStep(result.state.projectPath) });
41
63
  });
@@ -49,7 +71,9 @@ export function createNovelAgentServer(options) {
49
71
  step: z.enum([
50
72
  'novel_metadata',
51
73
  'story_bible',
74
+ 'style_guide',
52
75
  'architecture',
76
+ 'architecture_extension',
53
77
  'chapter',
54
78
  'memory_card',
55
79
  'continuity_review',
@@ -64,6 +88,8 @@ export function createNovelAgentServer(options) {
64
88
  projectPath: z.string().min(1),
65
89
  purpose: z.enum([
66
90
  'chapter_generation',
91
+ 'style_guide',
92
+ 'architecture_extension',
67
93
  'memory_extraction',
68
94
  'continuity_review',
69
95
  'revision',
@@ -177,6 +203,7 @@ export function createNovelAgentServer(options) {
177
203
  step: z.enum([
178
204
  'novel_metadata',
179
205
  'story_bible',
206
+ 'style_guide',
180
207
  'architecture',
181
208
  'chapter',
182
209
  'memory_card',
@@ -187,13 +214,14 @@ export function createNovelAgentServer(options) {
187
214
  // ===== MCP Prompts (slash commands) =====
188
215
  server.prompt('nf-start', 'Start a brand new novel project under the configured workspace.', {
189
216
  prompt: z.string().describe('User idea / premise / genre, in any language.'),
190
- chapters: z.string().optional().describe('Target number of chapters as a string. Defaults to 5.'),
191
- }, ({ prompt, chapters }) => ({
217
+ chapters: z.string().optional().describe('Planning batch size as a string. Defaults to 5.'),
218
+ totalChapters: z.string().optional().describe('Whole-book target chapter count as a string. Defaults to 12.'),
219
+ }, ({ prompt, chapters, totalChapters }) => ({
192
220
  messages: [{
193
221
  role: 'user',
194
222
  content: {
195
223
  type: 'text',
196
- text: `Use the novelforge MCP server. Call start_novel_project with prompt="${prompt}", targetChapters=${chapters ?? '5'}, then enter the autonomous loop: read next.instruction, generate the requested content, call submit_step_result, repeat until currentStep is "complete". Show me the projectPath after start_novel_project returns.`,
224
+ text: `Use the novelforge MCP server. Call start_novel_project with prompt="${prompt}", targetChapters=${chapters ?? '5'}, plannedTotalChapters=${totalChapters ?? '12'}, then enter the autonomous loop: read next.instruction, generate the requested content, call submit_step_result, repeat until currentStep is "complete". Show me the projectPath after start_novel_project returns.`,
197
225
  },
198
226
  }],
199
227
  }));
@@ -213,7 +241,7 @@ export function createNovelAgentServer(options) {
213
241
  role: 'user',
214
242
  content: {
215
243
  type: 'text',
216
- text: 'Use the novelforge MCP server: call list_projects with no arguments. Show me the result in a compact table (title, currentStep, chaptersWritten/targetChapters, updatedAt, projectPath).',
244
+ text: 'Use the novelforge MCP server: call list_projects with no arguments. Show me the result in a compact table (title, currentStep, chaptersWritten/plannedTotalChapters, updatedAt, projectPath).',
217
245
  },
218
246
  }],
219
247
  }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "novelforge-agent",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Local-first long-form novel workflow engine for any MCP host (Claude Code, Codex CLI, …) or CLI. State machine + zod schemas + BM25 retrieval + persistent project state. No LLM dependency.",
5
5
  "keywords": [
6
6
  "mcp",
package/src/cli/index.ts CHANGED
@@ -54,9 +54,17 @@ export async function runCli(argv = process.argv.slice(2), cwd = process.cwd()):
54
54
  const prompt = valueAfter(argv, '--prompt') || '';
55
55
  if (!prompt.trim()) throw new Error('Missing --prompt');
56
56
  const language = parseLanguage(valueAfter(argv, '--language') || 'zh-CN');
57
- const chapters = Number(valueAfter(argv, '--chapters') || 3);
57
+ const chapters = Number(valueAfter(argv, '--chapters') || 5);
58
+ const totalChapters = Number(valueAfter(argv, '--total-chapters') || 12);
58
59
  const outputDir = valueAfter(argv, '--output') || 'novels';
59
- const result = await createProject({ workspaceRoot: cwd, prompt, language, outputDir, targetChapters: chapters });
60
+ const result = await createProject({
61
+ workspaceRoot: cwd,
62
+ prompt,
63
+ language,
64
+ outputDir,
65
+ targetChapters: chapters,
66
+ plannedTotalChapters: totalChapters,
67
+ });
60
68
  const next = await getNextStep(result.state.projectPath);
61
69
  console.log(JSON.stringify({ state: result.state, next }, null, 2));
62
70
  return;
@@ -217,7 +225,7 @@ export async function runCli(argv = process.argv.slice(2), cwd = process.cwd()):
217
225
  if (command === 'redo') {
218
226
  if (!projectPath) throw new Error('Missing projectPath');
219
227
  const step = valueAfter(argv, '--step') as
220
- | 'novel_metadata' | 'story_bible' | 'architecture' | 'chapter' | 'memory_card' | 'continuity_review'
228
+ | 'novel_metadata' | 'story_bible' | 'style_guide' | 'architecture' | 'chapter' | 'memory_card' | 'continuity_review'
221
229
  | undefined;
222
230
  if (!step) throw new Error('Missing --step');
223
231
  const chapter = valueAfter(argv, '--chapter');
@@ -6,6 +6,8 @@ import { activeThreads, loadThreads } from './threadStore.js';
6
6
 
7
7
  export type ContextPurpose =
8
8
  | 'chapter_generation'
9
+ | 'style_guide'
10
+ | 'architecture_extension'
9
11
  | 'memory_extraction'
10
12
  | 'continuity_review'
11
13
  | 'revision'
@@ -32,12 +34,14 @@ export async function buildContext(input: BuildContextInput): Promise<string> {
32
34
  const parts: string[] = [];
33
35
  const metadata = await readOptional(join(input.projectPath, 'novel.json'));
34
36
  const storyBible = await readOptional(join(input.projectPath, 'story-bible.md'));
37
+ const styleGuideJson = await readOptional(join(input.projectPath, 'style-guide.json'));
35
38
  const chaptersJson = await readOptional(join(input.projectPath, 'architecture/chapters.json'));
36
39
  const charactersJson = await readOptional(join(input.projectPath, 'characters.json'));
37
40
  const volumePacingJson = await readOptional(join(input.projectPath, 'architecture/volume-pacing.json'));
38
41
 
39
42
  if (metadata) parts.push(`## Novel Metadata\n${metadata}`);
40
43
  if (storyBible) parts.push(`## Story Bible\n${storyBible.slice(0, 4000)}`);
44
+ if (styleGuideJson) parts.push(`## Style Guide\n${styleGuideJson}`);
41
45
  if (charactersJson) parts.push(`## Character State Table\n${charactersJson}`);
42
46
 
43
47
  function addVolumePacing(volumeId?: string): void {
@@ -113,6 +117,32 @@ export async function buildContext(input: BuildContextInput): Promise<string> {
113
117
  if (memoryParts.length) parts.push(`## Memory Cards\n${memoryParts.join('\n')}`);
114
118
  }
115
119
 
120
+ if (input.purpose === 'architecture_extension') {
121
+ if (chaptersJson) parts.push(`## Existing Chapter Architecture List\n${chaptersJson}`);
122
+ if (volumePacingJson) parts.push(`## Existing Volume Pacing Boards\n${volumePacingJson}`);
123
+
124
+ const start = Math.max(1, (input.chapterNumber ?? 1) - 5);
125
+ const end = Math.max(0, (input.chapterNumber ?? 1) - 1);
126
+ const memoryParts: string[] = [];
127
+ for (let i = start; i <= end; i += 1) {
128
+ const memory = await readOptional(join(input.projectPath, 'memory', memoryFileName(i)));
129
+ if (memory) memoryParts.push(`### Chapter ${i} Memory\n${memory}`);
130
+ }
131
+ if (memoryParts.length) parts.push(`## Recent Memory Cards\n${memoryParts.join('\n')}`);
132
+
133
+ const allThreads = await loadThreads(input.projectPath);
134
+ const active = activeThreads(allThreads);
135
+ if (active.length) {
136
+ const lines = active.map((t) => {
137
+ const flags: string[] = [`#${t.id}`, `status=${t.status}`, `planted=ch${t.plantedAt}`];
138
+ if (t.plannedPayoffAt) flags.push(`payoff=ch${t.plannedPayoffAt}`);
139
+ if (t.lastTouchedAt !== t.plantedAt) flags.push(`touched=ch${t.lastTouchedAt}`);
140
+ return `- ${t.description} (${flags.join(', ')})`;
141
+ });
142
+ parts.push(`## Active Foreshadow Threads\n${lines.join('\n')}`);
143
+ }
144
+ }
145
+
116
146
  if (input.purpose === 'chapter_review' && input.chapterNumber) {
117
147
  if (chaptersJson) {
118
148
  const chapters = JSON.parse(chaptersJson) as Array<{ chapterNumber: number; title: string; summary: string; requiredBeats?: string[]; volumeId?: string }>;
@@ -12,6 +12,7 @@ export interface ProjectSummary {
12
12
  currentStep: WorkflowStep;
13
13
  currentChapter: number;
14
14
  targetChapters: number;
15
+ plannedTotalChapters: number;
15
16
  completedSteps: number;
16
17
  chaptersWritten: number;
17
18
  updatedAt: string;
@@ -57,6 +58,7 @@ async function summarizeOne(projectPath: string): Promise<ProjectSummary | undef
57
58
  currentStep: state.currentStep,
58
59
  currentChapter: state.currentChapter,
59
60
  targetChapters: state.targetChapters,
61
+ plannedTotalChapters: state.plannedTotalChapters ?? state.targetChapters,
60
62
  completedSteps: state.completedSteps.length,
61
63
  chaptersWritten: countChapters(state),
62
64
  updatedAt: state.updatedAt,
@@ -164,6 +164,7 @@ export interface RedoStepResult {
164
164
  const STEP_FILE_KEYS: Partial<Record<WorkflowStep, string[]>> = {
165
165
  novel_metadata: ['novel'],
166
166
  story_bible: ['storyBible'],
167
+ style_guide: ['styleGuide'],
167
168
  architecture: ['architecture'],
168
169
  continuity_review: ['continuityReview'],
169
170
  };
@@ -171,6 +172,7 @@ const STEP_FILE_KEYS: Partial<Record<WorkflowStep, string[]>> = {
171
172
  const STEP_FILE_PATHS: Partial<Record<WorkflowStep, string[]>> = {
172
173
  novel_metadata: ['novel.json'],
173
174
  story_bible: ['story-bible.md'],
175
+ style_guide: ['style-guide.json'],
174
176
  architecture: ['architecture/full.md', 'architecture/volumes.json', 'architecture/chapters.json'],
175
177
  };
176
178
 
@@ -198,7 +200,13 @@ export async function redoStep(input: RedoStepInput): Promise<RedoStepResult> {
198
200
  state.currentChapter = chapter;
199
201
  state.currentStep = input.step;
200
202
  state.pendingAction = undefined;
201
- } else if (input.step === 'novel_metadata' || input.step === 'story_bible' || input.step === 'architecture' || input.step === 'continuity_review') {
203
+ } else if (
204
+ input.step === 'novel_metadata'
205
+ || input.step === 'story_bible'
206
+ || input.step === 'style_guide'
207
+ || input.step === 'architecture'
208
+ || input.step === 'continuity_review'
209
+ ) {
202
210
  const paths = STEP_FILE_PATHS[input.step] ?? [];
203
211
  for (const p of paths) {
204
212
  if (await tryUnlink(join(state.projectPath, p))) removed.push(p);
@@ -10,6 +10,7 @@ export interface CreateProjectInput {
10
10
  language?: AgentState['language'];
11
11
  outputDir?: string;
12
12
  targetChapters?: number;
13
+ plannedTotalChapters?: number;
13
14
  }
14
15
 
15
16
  export interface CreateProjectResult {
@@ -61,7 +62,12 @@ export async function archiveStoryBible(projectPath: string, versionRelative: st
61
62
  export async function createProject(input: CreateProjectInput): Promise<CreateProjectResult> {
62
63
  const workspaceRoot = resolve(input.workspaceRoot);
63
64
  const baseDir = input.outputDir || 'novels';
64
- const targetChapters = Math.max(1, Math.floor(Number(input.targetChapters || 3)));
65
+ const hasExplicitTargetChapters = input.targetChapters !== undefined;
66
+ const targetChapters = Math.max(1, Math.floor(Number(input.targetChapters || 5)));
67
+ const plannedTotalChapters = Math.max(
68
+ targetChapters,
69
+ Math.floor(Number(input.plannedTotalChapters ?? (hasExplicitTargetChapters ? targetChapters : 12)))
70
+ );
65
71
  const baseSlug = makeProjectSlug(input.prompt.slice(0, 48));
66
72
  const suffix = randomBytes(3).toString('hex');
67
73
  const slug = `${baseSlug}-${suffix}`;
@@ -76,6 +82,7 @@ export async function createProject(input: CreateProjectInput): Promise<CreatePr
76
82
  initialPrompt: input.prompt,
77
83
  language: input.language || 'zh-CN',
78
84
  targetChapters,
85
+ plannedTotalChapters,
79
86
  currentStep: 'novel_metadata',
80
87
  currentChapter: 1,
81
88
  completedSteps: [],