novelforge-agent 0.2.0 → 0.4.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 (40) hide show
  1. package/README.md +17 -13
  2. package/dist/src/cli/index.js +10 -2
  3. package/dist/src/core/contextBuilder.js +32 -0
  4. package/dist/src/core/fileNames.js +3 -3
  5. package/dist/src/core/projectDiscovery.js +1 -0
  6. package/dist/src/core/projectOps.js +7 -1
  7. package/dist/src/core/projectStore.js +4 -1
  8. package/dist/src/core/prompts/en-US.js +130 -3
  9. package/dist/src/core/prompts/zh-CN.js +130 -3
  10. package/dist/src/core/schemas.js +23 -0
  11. package/dist/src/core/steps/architectureExtension.js +72 -0
  12. package/dist/src/core/steps/chapterReview.js +2 -1
  13. package/dist/src/core/steps/index.js +4 -0
  14. package/dist/src/core/steps/memoryCard.js +22 -1
  15. package/dist/src/core/steps/novelMetadata.js +47 -2
  16. package/dist/src/core/steps/storyBible.js +1 -1
  17. package/dist/src/core/steps/styleGuide.js +12 -0
  18. package/dist/src/core/workflow.js +2 -0
  19. package/dist/src/mcp/tools.js +36 -8
  20. package/package.json +1 -1
  21. package/src/cli/index.ts +11 -3
  22. package/src/core/contextBuilder.ts +30 -0
  23. package/src/core/fileNames.ts +3 -3
  24. package/src/core/projectDiscovery.ts +2 -0
  25. package/src/core/projectOps.ts +9 -1
  26. package/src/core/projectStore.ts +8 -1
  27. package/src/core/prompts/en-US.ts +132 -3
  28. package/src/core/prompts/types.ts +2 -0
  29. package/src/core/prompts/zh-CN.ts +132 -3
  30. package/src/core/schemas.ts +25 -0
  31. package/src/core/steps/architectureExtension.ts +88 -0
  32. package/src/core/steps/chapterReview.ts +2 -1
  33. package/src/core/steps/index.ts +4 -0
  34. package/src/core/steps/memoryCard.ts +23 -1
  35. package/src/core/steps/novelMetadata.ts +48 -2
  36. package/src/core/steps/storyBible.ts +1 -1
  37. package/src/core/steps/styleGuide.ts +13 -0
  38. package/src/core/types.ts +32 -0
  39. package/src/core/workflow.ts +2 -0
  40. package/src/mcp/tools.ts +35 -8
@@ -12,6 +12,22 @@ export const NovelMetadataSchema = z.object({
12
12
  style: z.string().min(1).default('清晰、连贯、适合长篇连载'),
13
13
  coreCast: z.array(CoreCastMemberSchema).min(1),
14
14
  });
15
+ export const StyleGuideSchema = z.object({
16
+ narrativeVoice: z.string().min(1),
17
+ pacing: z.string().min(1),
18
+ diction: z.string().min(1),
19
+ dialogueRules: z.array(z.string().min(1)).min(1),
20
+ prohibitedPatterns: z.array(z.string().min(1)).min(1),
21
+ proseRhythm: z.object({
22
+ sentenceRhythm: z.string().min(1),
23
+ paragraphing: z.string().min(1),
24
+ interiorityMode: z.string().min(1),
25
+ emphasisBudget: z.string().min(1),
26
+ antiPatterns: z.array(z.string().min(1)).min(1),
27
+ }),
28
+ sampleParagraph: z.string().min(1),
29
+ consistencyChecks: z.array(z.string().min(1)).min(1),
30
+ });
15
31
  export const VolumeArchitectureSchema = z.object({
16
32
  id: z.string().min(1),
17
33
  title: z.string().min(1),
@@ -53,6 +69,12 @@ export const ArchitecturePayloadSchema = z.object({
53
69
  volumePacing: z.array(VolumePacingBoardSchema).optional(),
54
70
  chapters: z.array(ChapterArchitectureSchema).min(1),
55
71
  });
72
+ export const ArchitectureExtensionPayloadSchema = z.object({
73
+ fullUpdate: z.string().min(1).optional(),
74
+ volumes: z.array(VolumeArchitectureSchema).optional(),
75
+ volumePacing: z.array(VolumePacingBoardSchema).optional(),
76
+ chapters: z.array(ChapterArchitectureSchema).min(1),
77
+ });
56
78
  export const ThreadActionSchema = z.object({
57
79
  kind: z.enum(['plant', 'build', 'pay', 'drop']),
58
80
  threadId: z.string().min(1).optional(),
@@ -163,6 +185,7 @@ export const ChapterAcceptanceGateSchema = z.object({
163
185
  characterProgress: ChapterAcceptanceCheckSchema,
164
186
  foreshadowProgress: ChapterAcceptanceCheckSchema,
165
187
  storyBibleConsistency: ChapterAcceptanceCheckSchema,
188
+ proseRhythm: ChapterAcceptanceCheckSchema,
166
189
  endingHook: ChapterAcceptanceCheckSchema,
167
190
  repetition: ChapterAcceptanceCheckSchema,
168
191
  });
@@ -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
  };
@@ -1,14 +1,59 @@
1
+ import { access, rename } from 'node:fs/promises';
2
+ import { basename, dirname, join } from 'node:path';
1
3
  import { NovelMetadataSchema } from '../schemas.js';
2
4
  import { saveJsonFile } from '../projectStore.js';
3
5
  import { initializeCharacterStates } from '../characterStore.js';
6
+ import { makeProjectSlug } from '../fileNames.js';
4
7
  import { parseJson } from './types.js';
8
+ async function pathExists(path) {
9
+ try {
10
+ await access(path);
11
+ return true;
12
+ }
13
+ catch {
14
+ return false;
15
+ }
16
+ }
17
+ async function uniqueProjectPath(parentDir, baseName, currentPath) {
18
+ let candidate = join(parentDir, baseName);
19
+ if (candidate === currentPath || !(await pathExists(candidate)))
20
+ return candidate;
21
+ for (let index = 2; index < 100; index += 1) {
22
+ candidate = join(parentDir, `${baseName}-${index}`);
23
+ if (candidate === currentPath || !(await pathExists(candidate)))
24
+ return candidate;
25
+ }
26
+ throw new Error(`Unable to find available project directory for ${baseName}`);
27
+ }
28
+ async function renameProjectForTitle(projectPath, title) {
29
+ const parentDir = dirname(projectPath);
30
+ const currentName = basename(projectPath);
31
+ const suffix = currentName.match(/-([a-f0-9]{6})$/i)?.[1];
32
+ const titleSlug = makeProjectSlug(title);
33
+ const nextName = suffix ? `${titleSlug}-${suffix}` : titleSlug;
34
+ if (nextName === currentName)
35
+ return projectPath;
36
+ const nextPath = await uniqueProjectPath(parentDir, nextName, projectPath);
37
+ if (nextPath === projectPath)
38
+ return projectPath;
39
+ await rename(projectPath, nextPath);
40
+ return nextPath;
41
+ }
5
42
  export const novelMetadataHandler = async (state, content) => {
6
43
  const parsed = NovelMetadataSchema.parse(parseJson(content));
7
44
  const path = await saveJsonFile(state.projectPath, 'novel.json', parsed);
8
45
  const charactersPath = await initializeCharacterStates(state.projectPath, parsed.coreCast);
46
+ const projectPath = await renameProjectForTitle(state.projectPath, parsed.title);
9
47
  return {
10
- savedPaths: [path, charactersPath],
48
+ savedPaths: [
49
+ projectPath === state.projectPath ? path : join(projectPath, 'novel.json'),
50
+ projectPath === state.projectPath ? charactersPath : join(projectPath, 'characters.json'),
51
+ ],
11
52
  fileEntries: { novel: 'novel.json', characters: 'characters.json' },
12
- next: { kind: 'linear', nextStep: 'story_bible' },
53
+ next: {
54
+ kind: 'linear',
55
+ nextStep: 'story_bible',
56
+ statePatch: { projectPath },
57
+ },
13
58
  };
14
59
  };
@@ -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.4.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 }>;
@@ -9,12 +9,12 @@ export function makeProjectSlug(title: string): string {
9
9
  const replaced = title
10
10
  .trim()
11
11
  .split('')
12
- .map((char) => PINYIN_FALLBACK[char] || char)
13
- .join('-')
12
+ .map((char) => PINYIN_FALLBACK[char] ? `-${PINYIN_FALLBACK[char]}-` : char)
13
+ .join('')
14
14
  .normalize('NFKD')
15
15
  .replace(/[\u0300-\u036f]/g, '')
16
16
  .toLowerCase()
17
- .replace(/[^a-z0-9]+/g, '-')
17
+ .replace(/[^\p{Letter}\p{Number}]+/gu, '-')
18
18
  .replace(/^-+|-+$/g, '');
19
19
  return replaced || `novel-${Date.now()}`;
20
20
  }
@@ -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: [],