novelforge-agent 0.1.1 → 0.2.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 (46) hide show
  1. package/README.md +36 -13
  2. package/dist/src/cli/index.js +71 -2
  3. package/dist/src/core/bibleStore.js +36 -0
  4. package/dist/src/core/characterStore.js +74 -0
  5. package/dist/src/core/contextBuilder.js +44 -1
  6. package/dist/src/core/fileNames.js +4 -0
  7. package/dist/src/core/index.js +4 -0
  8. package/dist/src/core/projectOps.js +187 -0
  9. package/dist/src/core/projectStore.js +11 -0
  10. package/dist/src/core/prompts/en-US.js +117 -13
  11. package/dist/src/core/prompts/zh-CN.js +116 -12
  12. package/dist/src/core/retrieval/index.js +8 -0
  13. package/dist/src/core/schemas.js +98 -1
  14. package/dist/src/core/steps/architecture.js +7 -1
  15. package/dist/src/core/steps/chapter.js +11 -1
  16. package/dist/src/core/steps/chapterReview.js +25 -1
  17. package/dist/src/core/steps/chapterRevision.js +17 -0
  18. package/dist/src/core/steps/memoryCard.js +4 -0
  19. package/dist/src/core/steps/novelMetadata.js +4 -2
  20. package/dist/src/core/threadStore.js +150 -0
  21. package/dist/src/core/workflow.js +3 -3
  22. package/dist/src/mcp/tools.js +198 -18
  23. package/package.json +5 -1
  24. package/src/cli/index.ts +74 -1
  25. package/src/core/bibleStore.ts +57 -0
  26. package/src/core/characterStore.ts +93 -0
  27. package/src/core/contextBuilder.ts +44 -4
  28. package/src/core/fileNames.ts +5 -0
  29. package/src/core/index.ts +4 -0
  30. package/src/core/projectOps.ts +243 -0
  31. package/src/core/projectStore.ts +11 -0
  32. package/src/core/prompts/en-US.ts +126 -22
  33. package/src/core/prompts/types.ts +2 -1
  34. package/src/core/prompts/zh-CN.ts +118 -14
  35. package/src/core/retrieval/index.ts +10 -0
  36. package/src/core/schemas.ts +108 -1
  37. package/src/core/steps/architecture.ts +7 -1
  38. package/src/core/steps/chapter.ts +11 -1
  39. package/src/core/steps/chapterReview.ts +27 -1
  40. package/src/core/steps/chapterRevision.ts +18 -0
  41. package/src/core/steps/memoryCard.ts +4 -0
  42. package/src/core/steps/novelMetadata.ts +4 -2
  43. package/src/core/threadStore.ts +173 -0
  44. package/src/core/types.ts +102 -1
  45. package/src/core/workflow.ts +3 -3
  46. package/src/mcp/tools.ts +322 -19
@@ -0,0 +1,57 @@
1
+ import { readdir } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import {
4
+ archiveStoryBible,
5
+ loadState,
6
+ saveMarkdownFile,
7
+ saveState,
8
+ } from './projectStore.js';
9
+ import { storyBibleVersionFileName } from './fileNames.js';
10
+ import { indexStoryBible } from './retrieval/index.js';
11
+
12
+ export interface AmendStoryBibleInput {
13
+ projectPath: string;
14
+ content: string;
15
+ reason?: string;
16
+ }
17
+
18
+ export interface AmendStoryBibleResult {
19
+ archivedPath?: string;
20
+ bibleVersion: number;
21
+ savedPath: string;
22
+ }
23
+
24
+ function isEmpty(content: string): boolean {
25
+ return !content || !content.trim();
26
+ }
27
+
28
+ export async function amendStoryBible(input: AmendStoryBibleInput): Promise<AmendStoryBibleResult> {
29
+ if (isEmpty(input.content)) throw new Error('Amended story bible content is empty');
30
+ const state = await loadState(input.projectPath);
31
+ // Archive current
32
+ const archived = await archiveStoryBible(
33
+ state.projectPath,
34
+ join('story-bible-versions', storyBibleVersionFileName(new Date().toISOString()))
35
+ );
36
+ // Save new
37
+ const savedPath = await saveMarkdownFile(state.projectPath, 'story-bible.md', input.content);
38
+ // Re-index
39
+ await indexStoryBible(state.projectPath, input.content);
40
+ // Track in state
41
+ const bibleVersion = (state.completedSteps.filter((s) => s === 'story_bible_amend').length ?? 0) + 1;
42
+ await saveState({
43
+ ...state,
44
+ completedSteps: [...state.completedSteps, 'story_bible_amend' as const],
45
+ files: { ...state.files, storyBible: 'story-bible.md' },
46
+ });
47
+ return { archivedPath: archived, bibleVersion, savedPath };
48
+ }
49
+
50
+ export async function listStoryBibleVersions(projectPath: string): Promise<string[]> {
51
+ try {
52
+ const items = await readdir(join(projectPath, 'story-bible-versions'));
53
+ return items.filter((f) => f.endsWith('.md')).sort();
54
+ } catch {
55
+ return [];
56
+ }
57
+ }
@@ -0,0 +1,93 @@
1
+ import { readFile, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { CharacterState, CharacterStateUpdate, CoreCastMember } from './types.js';
4
+
5
+ const CHARACTERS_FILE = 'characters.json';
6
+
7
+ export interface CharactersBundle {
8
+ characters: CharacterState[];
9
+ }
10
+
11
+ function emptyState(member: CoreCastMember, chapterNumber = 0): CharacterState {
12
+ return {
13
+ name: member.name,
14
+ role: member.role,
15
+ goal: '未确认',
16
+ belief: '未确认',
17
+ relationships: [],
18
+ abilities: [],
19
+ secrets: [],
20
+ emotionalState: member.description,
21
+ lastUpdatedAt: chapterNumber,
22
+ };
23
+ }
24
+
25
+ export async function loadCharacterStates(projectPath: string): Promise<CharacterState[]> {
26
+ try {
27
+ const raw = await readFile(join(projectPath, CHARACTERS_FILE), 'utf8');
28
+ const parsed = JSON.parse(raw) as CharactersBundle;
29
+ return Array.isArray(parsed.characters) ? parsed.characters : [];
30
+ } catch {
31
+ return [];
32
+ }
33
+ }
34
+
35
+ export async function saveCharacterStates(projectPath: string, characters: CharacterState[]): Promise<string> {
36
+ const fullPath = join(projectPath, CHARACTERS_FILE);
37
+ await writeFile(fullPath, `${JSON.stringify({ characters }, null, 2)}\n`, 'utf8');
38
+ return fullPath;
39
+ }
40
+
41
+ export async function initializeCharacterStates(
42
+ projectPath: string,
43
+ coreCast: CoreCastMember[]
44
+ ): Promise<string> {
45
+ const existing = await loadCharacterStates(projectPath);
46
+ const byName = new Map(existing.map((c) => [c.name, c]));
47
+ for (const member of coreCast) {
48
+ if (!byName.has(member.name)) {
49
+ byName.set(member.name, emptyState(member));
50
+ }
51
+ }
52
+ return saveCharacterStates(projectPath, Array.from(byName.values()));
53
+ }
54
+
55
+ export async function applyCharacterUpdates(
56
+ projectPath: string,
57
+ chapterNumber: number,
58
+ updates: CharacterStateUpdate[] | undefined
59
+ ): Promise<CharacterState[]> {
60
+ const existing = await loadCharacterStates(projectPath);
61
+ if (!updates || !updates.length) return existing;
62
+
63
+ const byName = new Map(existing.map((c) => [c.name, { ...c }]));
64
+ for (const update of updates) {
65
+ const current = byName.get(update.name) ?? {
66
+ name: update.name,
67
+ role: update.role,
68
+ goal: '未确认',
69
+ belief: '未确认',
70
+ relationships: [],
71
+ abilities: [],
72
+ secrets: [],
73
+ emotionalState: '未确认',
74
+ lastUpdatedAt: chapterNumber,
75
+ };
76
+
77
+ byName.set(update.name, {
78
+ ...current,
79
+ role: update.role ?? current.role,
80
+ goal: update.goal ?? current.goal,
81
+ belief: update.belief ?? current.belief,
82
+ relationships: update.relationships ?? current.relationships,
83
+ abilities: update.abilities ?? current.abilities,
84
+ secrets: update.secrets ?? current.secrets,
85
+ emotionalState: update.emotionalState ?? current.emotionalState,
86
+ lastUpdatedAt: chapterNumber,
87
+ });
88
+ }
89
+
90
+ const next = Array.from(byName.values()).sort((a, b) => a.name.localeCompare(b.name));
91
+ await saveCharacterStates(projectPath, next);
92
+ return next;
93
+ }
@@ -2,6 +2,7 @@ import { readFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { chapterFileName, chapterReviewFileName, memoryFileName } from './fileNames.js';
4
4
  import { formatHits, retrieve } from './retrieval/index.js';
5
+ import { activeThreads, loadThreads } from './threadStore.js';
5
6
 
6
7
  export type ContextPurpose =
7
8
  | 'chapter_generation'
@@ -32,18 +33,33 @@ export async function buildContext(input: BuildContextInput): Promise<string> {
32
33
  const metadata = await readOptional(join(input.projectPath, 'novel.json'));
33
34
  const storyBible = await readOptional(join(input.projectPath, 'story-bible.md'));
34
35
  const chaptersJson = await readOptional(join(input.projectPath, 'architecture/chapters.json'));
36
+ const charactersJson = await readOptional(join(input.projectPath, 'characters.json'));
37
+ const volumePacingJson = await readOptional(join(input.projectPath, 'architecture/volume-pacing.json'));
35
38
 
36
39
  if (metadata) parts.push(`## Novel Metadata\n${metadata}`);
37
40
  if (storyBible) parts.push(`## Story Bible\n${storyBible.slice(0, 4000)}`);
41
+ if (charactersJson) parts.push(`## Character State Table\n${charactersJson}`);
42
+
43
+ function addVolumePacing(volumeId?: string): void {
44
+ if (!volumePacingJson) return;
45
+ try {
46
+ const boards = JSON.parse(volumePacingJson) as Array<{ volumeId: string }>;
47
+ const board = volumeId ? boards.find((item) => item.volumeId === volumeId) : undefined;
48
+ parts.push(`## Volume Pacing Board\n${JSON.stringify(board ?? boards, null, 2)}`);
49
+ } catch {
50
+ parts.push(`## Volume Pacing Board\n${volumePacingJson}`);
51
+ }
52
+ }
38
53
 
39
54
  if (input.purpose === 'chapter_generation' && input.chapterNumber) {
40
- let currentArchitectureForQuery: { summary?: string; requiredBeats?: string[]; title?: string } | undefined;
55
+ let currentArchitectureForQuery: { summary?: string; requiredBeats?: string[]; title?: string; volumeId?: string } | undefined;
41
56
  if (chaptersJson) {
42
- const chapters = JSON.parse(chaptersJson) as Array<{ chapterNumber: number; title: string; summary: string; requiredBeats?: string[] }>;
57
+ const chapters = JSON.parse(chaptersJson) as Array<{ chapterNumber: number; title: string; summary: string; requiredBeats?: string[]; volumeId?: string }>;
43
58
  const chapter = chapters.find((item) => item.chapterNumber === input.chapterNumber);
44
59
  if (chapter) {
45
60
  currentArchitectureForQuery = chapter;
46
61
  parts.push(`## Current Chapter Architecture\n${JSON.stringify(chapter, null, 2)}`);
62
+ addVolumePacing(chapter.volumeId);
47
63
  }
48
64
  }
49
65
  if (input.chapterNumber > 1) {
@@ -67,6 +83,18 @@ export async function buildContext(input: BuildContextInput): Promise<string> {
67
83
  const formatted = formatHits(hits);
68
84
  if (formatted) parts.push(`## Retrieved Relevant Snippets (lexical, BM25-style)\n${formatted}`);
69
85
  }
86
+
87
+ const allThreads = await loadThreads(input.projectPath);
88
+ const active = activeThreads(allThreads);
89
+ if (active.length) {
90
+ const lines = active.map((t) => {
91
+ const flags: string[] = [`#${t.id}`, `status=${t.status}`, `planted=ch${t.plantedAt}`];
92
+ if (t.plannedPayoffAt) flags.push(`payoff=ch${t.plannedPayoffAt}`);
93
+ if (t.lastTouchedAt !== t.plantedAt) flags.push(`touched=ch${t.lastTouchedAt}`);
94
+ return `- ${t.description} (${flags.join(', ')})`;
95
+ });
96
+ parts.push(`## Active Foreshadow Threads (do not silently drop or contradict)\n${lines.join('\n')}`);
97
+ }
70
98
  }
71
99
  }
72
100
 
@@ -87,9 +115,12 @@ export async function buildContext(input: BuildContextInput): Promise<string> {
87
115
 
88
116
  if (input.purpose === 'chapter_review' && input.chapterNumber) {
89
117
  if (chaptersJson) {
90
- const chapters = JSON.parse(chaptersJson) as Array<{ chapterNumber: number; title: string; summary: string; requiredBeats?: string[] }>;
118
+ const chapters = JSON.parse(chaptersJson) as Array<{ chapterNumber: number; title: string; summary: string; requiredBeats?: string[]; volumeId?: string }>;
91
119
  const arch = chapters.find((item) => item.chapterNumber === input.chapterNumber);
92
- if (arch) parts.push(`## Target Chapter Architecture\n${JSON.stringify(arch, null, 2)}`);
120
+ if (arch) {
121
+ parts.push(`## Target Chapter Architecture\n${JSON.stringify(arch, null, 2)}`);
122
+ addVolumePacing(arch.volumeId);
123
+ }
93
124
  }
94
125
  const chapter = await readOptional(join(input.projectPath, 'chapters', chapterFileName(input.chapterNumber)));
95
126
  if (chapter) parts.push(`## Chapter ${input.chapterNumber} Text\n${chapter}`);
@@ -102,6 +133,15 @@ export async function buildContext(input: BuildContextInput): Promise<string> {
102
133
  if (input.purpose === 'revision' && input.chapterNumber) {
103
134
  const chapter = await readOptional(join(input.projectPath, 'chapters', chapterFileName(input.chapterNumber)));
104
135
  if (chapter) parts.push(`## Current Chapter Text\n${chapter}`);
136
+ if (chaptersJson) {
137
+ try {
138
+ const chapters = JSON.parse(chaptersJson) as Array<{ chapterNumber: number; volumeId?: string }>;
139
+ const arch = chapters.find((item) => item.chapterNumber === input.chapterNumber);
140
+ addVolumePacing(arch?.volumeId);
141
+ } catch {
142
+ // ignore malformed architecture here; review feedback is still useful
143
+ }
144
+ }
105
145
  const review = await readOptional(join(input.projectPath, 'reviews/chapter', chapterReviewFileName(input.chapterNumber)));
106
146
  if (review) parts.push(`## Editor Review\n${review}`);
107
147
  if (input.feedback) parts.push(`## Additional Feedback\n${input.feedback}`);
@@ -46,3 +46,8 @@ export function chapterVersionFileName(chapterNumber: number, timestamp: string)
46
46
  const safeTs = timestamp.replace(/[:.]/g, '-');
47
47
  return `${padChapterNumber(chapterNumber)}.${safeTs}.md`;
48
48
  }
49
+
50
+ export function storyBibleVersionFileName(timestamp: string): string {
51
+ const safeTs = timestamp.replace(/[:.]/g, '-');
52
+ return `story-bible.${safeTs}.md`;
53
+ }
package/src/core/index.ts CHANGED
@@ -7,3 +7,7 @@ export * from './contextBuilder.js';
7
7
  export * from './prompts.js';
8
8
  export * from './retrieval/index.js';
9
9
  export * from './projectDiscovery.js';
10
+ export * from './threadStore.js';
11
+ export * from './bibleStore.js';
12
+ export * from './projectOps.js';
13
+ export * from './characterStore.js';
@@ -0,0 +1,243 @@
1
+ import { randomBytes, randomUUID } from 'node:crypto';
2
+ import { cp, readdir, rm, unlink, writeFile } from 'node:fs/promises';
3
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
4
+ import { AgentState, WorkflowStep } from './types.js';
5
+ import { loadState, saveState } from './projectStore.js';
6
+ import { indexChapter, removeChapterFromIndex, removeMemoryCardFromIndex } from './retrieval/index.js';
7
+ import { chapterFileName, memoryFileName } from './fileNames.js';
8
+
9
+ // =============================================================================
10
+ // fork_project
11
+ // =============================================================================
12
+
13
+ export interface ForkProjectInput {
14
+ sourceProjectPath: string;
15
+ label?: string;
16
+ }
17
+
18
+ export interface ForkProjectResult {
19
+ newProjectPath: string;
20
+ newProjectId: string;
21
+ }
22
+
23
+ export async function forkProject(input: ForkProjectInput): Promise<ForkProjectResult> {
24
+ const source = resolve(input.sourceProjectPath);
25
+ const state = await loadState(source);
26
+ const suffix = randomBytes(3).toString('hex');
27
+ const label = (input.label ?? 'fork').replace(/[^a-z0-9-]+/gi, '-').replace(/^-+|-+$/g, '').toLowerCase() || 'fork';
28
+ const targetName = `${basename(source)}-${label}-${suffix}`;
29
+ const target = join(dirname(source), targetName);
30
+
31
+ await cp(source, target, { recursive: true });
32
+
33
+ const forkedState: AgentState = {
34
+ ...state,
35
+ projectId: randomUUID(),
36
+ projectPath: target,
37
+ createdAt: new Date().toISOString(),
38
+ updatedAt: new Date().toISOString(),
39
+ };
40
+ await saveState(forkedState);
41
+ return { newProjectPath: target, newProjectId: forkedState.projectId };
42
+ }
43
+
44
+ // =============================================================================
45
+ // delete_chapter
46
+ // =============================================================================
47
+
48
+ export interface DeleteChapterInput {
49
+ projectPath: string;
50
+ chapterNumber: number;
51
+ }
52
+
53
+ export interface DeleteChapterResult {
54
+ removed: string[];
55
+ newCurrentChapter: number;
56
+ newCurrentStep: WorkflowStep;
57
+ }
58
+
59
+ async function tryUnlink(path: string): Promise<boolean> {
60
+ try {
61
+ await unlink(path);
62
+ return true;
63
+ } catch {
64
+ return false;
65
+ }
66
+ }
67
+
68
+ async function tryRmDirEntry(dirPath: string, prefix: string): Promise<string[]> {
69
+ const removed: string[] = [];
70
+ try {
71
+ const items = await readdir(dirPath);
72
+ for (const item of items) {
73
+ if (item.startsWith(prefix)) {
74
+ const full = join(dirPath, item);
75
+ try {
76
+ await unlink(full);
77
+ removed.push(full);
78
+ } catch {
79
+ // ignore
80
+ }
81
+ }
82
+ }
83
+ } catch {
84
+ // dir absent
85
+ }
86
+ return removed;
87
+ }
88
+
89
+ export async function deleteChapter(input: DeleteChapterInput): Promise<DeleteChapterResult> {
90
+ const state = await loadState(input.projectPath);
91
+ const n = input.chapterNumber;
92
+ if (n < 1) throw new Error('chapterNumber must be >= 1');
93
+
94
+ const removed: string[] = [];
95
+ const chapterRel = join('chapters', chapterFileName(n));
96
+ if (await tryUnlink(join(state.projectPath, chapterRel))) removed.push(chapterRel);
97
+
98
+ const memoryRel = join('memory', memoryFileName(n));
99
+ if (await tryUnlink(join(state.projectPath, memoryRel))) removed.push(memoryRel);
100
+
101
+ // Versions of this chapter
102
+ const versionsRemoved = await tryRmDirEntry(
103
+ join(state.projectPath, 'chapters/.versions'),
104
+ `${chapterFileName(n).replace(/\.md$/, '')}.`
105
+ );
106
+ removed.push(...versionsRemoved);
107
+
108
+ // Per-chapter review
109
+ const reviewName = `chapter-${String(n).padStart(3, '0')}.json`;
110
+ if (await tryUnlink(join(state.projectPath, 'reviews/chapter', reviewName))) {
111
+ removed.push(`reviews/chapter/${reviewName}`);
112
+ }
113
+
114
+ // Update state.files
115
+ const nextFiles: Record<string, string> = { ...state.files };
116
+ delete nextFiles[`chapter-${n}`];
117
+ delete nextFiles[`memory-${n}`];
118
+ delete nextFiles[`review-chapter-${n}`];
119
+
120
+ // Remove this chapter and its memory card from the lexical index
121
+ await removeChapterFromIndex(state.projectPath, n);
122
+ await removeMemoryCardFromIndex(state.projectPath, n);
123
+
124
+ // Adjust state.currentChapter & currentStep if needed
125
+ let newCurrentChapter = state.currentChapter;
126
+ let newCurrentStep: WorkflowStep = state.currentStep;
127
+ if (state.currentChapter > n) {
128
+ // user deleted an earlier chapter; current pointer becomes the deleted one to be regenerated
129
+ newCurrentChapter = n;
130
+ newCurrentStep = 'chapter';
131
+ } else if (state.currentChapter === n + 1 && (state.currentStep === 'chapter' || state.currentStep === 'memory_card')) {
132
+ // we just finished chapter n and were about to do n+1; step back
133
+ newCurrentChapter = n;
134
+ newCurrentStep = 'chapter';
135
+ }
136
+
137
+ const nextState: AgentState = {
138
+ ...state,
139
+ files: nextFiles,
140
+ currentChapter: newCurrentChapter,
141
+ currentStep: newCurrentStep,
142
+ pendingAction: undefined,
143
+ };
144
+ await saveState(nextState);
145
+ return { removed, newCurrentChapter, newCurrentStep };
146
+ }
147
+
148
+ // =============================================================================
149
+ // redo_step
150
+ // =============================================================================
151
+
152
+ export interface RedoStepInput {
153
+ projectPath: string;
154
+ step: WorkflowStep;
155
+ chapterNumber?: number;
156
+ }
157
+
158
+ export interface RedoStepResult {
159
+ removed: string[];
160
+ currentStep: WorkflowStep;
161
+ currentChapter: number;
162
+ }
163
+
164
+ const STEP_FILE_KEYS: Partial<Record<WorkflowStep, string[]>> = {
165
+ novel_metadata: ['novel'],
166
+ story_bible: ['storyBible'],
167
+ architecture: ['architecture'],
168
+ continuity_review: ['continuityReview'],
169
+ };
170
+
171
+ const STEP_FILE_PATHS: Partial<Record<WorkflowStep, string[]>> = {
172
+ novel_metadata: ['novel.json'],
173
+ story_bible: ['story-bible.md'],
174
+ architecture: ['architecture/full.md', 'architecture/volumes.json', 'architecture/chapters.json'],
175
+ };
176
+
177
+ export async function redoStep(input: RedoStepInput): Promise<RedoStepResult> {
178
+ const state = await loadState(input.projectPath);
179
+ const removed: string[] = [];
180
+
181
+ if (input.step === 'chapter' || input.step === 'memory_card') {
182
+ const chapter = input.chapterNumber ?? state.currentChapter;
183
+ if (input.step === 'memory_card') {
184
+ const rel = join('memory', memoryFileName(chapter));
185
+ if (await tryUnlink(join(state.projectPath, rel))) removed.push(rel);
186
+ delete state.files[`memory-${chapter}`];
187
+ } else {
188
+ // chapter: also remove its memory + per-chapter review since they depend on it
189
+ const cRel = join('chapters', chapterFileName(chapter));
190
+ if (await tryUnlink(join(state.projectPath, cRel))) removed.push(cRel);
191
+ const mRel = join('memory', memoryFileName(chapter));
192
+ if (await tryUnlink(join(state.projectPath, mRel))) removed.push(mRel);
193
+ delete state.files[`chapter-${chapter}`];
194
+ delete state.files[`memory-${chapter}`];
195
+ await removeChapterFromIndex(state.projectPath, chapter);
196
+ await removeMemoryCardFromIndex(state.projectPath, chapter);
197
+ }
198
+ state.currentChapter = chapter;
199
+ state.currentStep = input.step;
200
+ state.pendingAction = undefined;
201
+ } else if (input.step === 'novel_metadata' || input.step === 'story_bible' || input.step === 'architecture' || input.step === 'continuity_review') {
202
+ const paths = STEP_FILE_PATHS[input.step] ?? [];
203
+ for (const p of paths) {
204
+ if (await tryUnlink(join(state.projectPath, p))) removed.push(p);
205
+ }
206
+ const keys = STEP_FILE_KEYS[input.step] ?? [];
207
+ for (const k of keys) {
208
+ delete state.files[k];
209
+ }
210
+ state.currentStep = input.step;
211
+ state.pendingAction = undefined;
212
+ if (input.step === 'novel_metadata') state.currentChapter = 1;
213
+ } else {
214
+ throw new Error(`redo_step does not support step: ${input.step}`);
215
+ }
216
+
217
+ // Trim completedSteps after the redo target
218
+ const idx = state.completedSteps.lastIndexOf(input.step);
219
+ if (idx >= 0) state.completedSteps = state.completedSteps.slice(0, idx);
220
+
221
+ await saveState(state);
222
+ return {
223
+ removed,
224
+ currentStep: state.currentStep,
225
+ currentChapter: state.currentChapter,
226
+ };
227
+ }
228
+
229
+ // =============================================================================
230
+ // guards
231
+ // =============================================================================
232
+
233
+ export function assertProjectPath(workspaceRoot: string, projectPath: string): void {
234
+ const root = resolve(workspaceRoot);
235
+ const target = resolve(projectPath);
236
+ const rel = relative(root, target);
237
+ if (rel.startsWith('..') || isAbsolute(rel)) {
238
+ throw new Error(`Refusing to operate outside workspace: ${target}`);
239
+ }
240
+ }
241
+
242
+ // keep tsc happy if no other refs
243
+ void writeFile;
@@ -30,6 +30,7 @@ export async function ensureProjectDirectories(projectPath: string): Promise<voi
30
30
  await mkdir(join(projectPath, 'architecture'), { recursive: true });
31
31
  await mkdir(join(projectPath, 'chapters'), { recursive: true });
32
32
  await mkdir(join(projectPath, 'chapters/.versions'), { recursive: true });
33
+ await mkdir(join(projectPath, 'story-bible-versions'), { recursive: true });
33
34
  await mkdir(join(projectPath, 'memory'), { recursive: true });
34
35
  await mkdir(join(projectPath, 'reviews'), { recursive: true });
35
36
  await mkdir(join(projectPath, 'reviews/chapter'), { recursive: true });
@@ -47,6 +48,16 @@ export async function archiveChapterVersion(projectPath: string, chapterRelative
47
48
  }
48
49
  }
49
50
 
51
+ export async function archiveStoryBible(projectPath: string, versionRelative: string): Promise<string | undefined> {
52
+ const sourcePath = join(projectPath, 'story-bible.md');
53
+ try {
54
+ const existing = await readFile(sourcePath, 'utf8');
55
+ return saveMarkdownFile(projectPath, versionRelative, existing);
56
+ } catch {
57
+ return undefined;
58
+ }
59
+ }
60
+
50
61
  export async function createProject(input: CreateProjectInput): Promise<CreateProjectResult> {
51
62
  const workspaceRoot = resolve(input.workspaceRoot);
52
63
  const baseDir = input.outputDir || 'novels';