novelforge-agent 0.1.0 → 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 (48) hide show
  1. package/README.md +82 -14
  2. package/dist/src/cli/index.js +92 -2
  3. package/dist/src/cli/install.js +224 -0
  4. package/dist/src/core/bibleStore.js +36 -0
  5. package/dist/src/core/characterStore.js +74 -0
  6. package/dist/src/core/contextBuilder.js +44 -1
  7. package/dist/src/core/fileNames.js +4 -0
  8. package/dist/src/core/index.js +4 -0
  9. package/dist/src/core/projectOps.js +187 -0
  10. package/dist/src/core/projectStore.js +11 -0
  11. package/dist/src/core/prompts/en-US.js +117 -13
  12. package/dist/src/core/prompts/zh-CN.js +116 -12
  13. package/dist/src/core/retrieval/index.js +8 -0
  14. package/dist/src/core/schemas.js +98 -1
  15. package/dist/src/core/steps/architecture.js +7 -1
  16. package/dist/src/core/steps/chapter.js +11 -1
  17. package/dist/src/core/steps/chapterReview.js +25 -1
  18. package/dist/src/core/steps/chapterRevision.js +17 -0
  19. package/dist/src/core/steps/memoryCard.js +4 -0
  20. package/dist/src/core/steps/novelMetadata.js +4 -2
  21. package/dist/src/core/threadStore.js +150 -0
  22. package/dist/src/core/workflow.js +3 -3
  23. package/dist/src/mcp/tools.js +198 -18
  24. package/package.json +5 -1
  25. package/src/cli/index.ts +94 -1
  26. package/src/cli/install.ts +275 -0
  27. package/src/core/bibleStore.ts +57 -0
  28. package/src/core/characterStore.ts +93 -0
  29. package/src/core/contextBuilder.ts +44 -4
  30. package/src/core/fileNames.ts +5 -0
  31. package/src/core/index.ts +4 -0
  32. package/src/core/projectOps.ts +243 -0
  33. package/src/core/projectStore.ts +11 -0
  34. package/src/core/prompts/en-US.ts +126 -22
  35. package/src/core/prompts/types.ts +2 -1
  36. package/src/core/prompts/zh-CN.ts +118 -14
  37. package/src/core/retrieval/index.ts +10 -0
  38. package/src/core/schemas.ts +108 -1
  39. package/src/core/steps/architecture.ts +7 -1
  40. package/src/core/steps/chapter.ts +11 -1
  41. package/src/core/steps/chapterReview.ts +27 -1
  42. package/src/core/steps/chapterRevision.ts +18 -0
  43. package/src/core/steps/memoryCard.ts +4 -0
  44. package/src/core/steps/novelMetadata.ts +4 -2
  45. package/src/core/threadStore.ts +173 -0
  46. package/src/core/types.ts +102 -1
  47. package/src/core/workflow.ts +3 -3
  48. package/src/mcp/tools.ts +322 -19
@@ -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';
@@ -106,6 +106,18 @@ Output valid JSON only, in this shape:
106
106
  "order": 1
107
107
  }
108
108
  ],
109
+ "volumePacing": [
110
+ {
111
+ "volumeId": "v1",
112
+ "start": "Volume starting state: protagonist/world/conflict",
113
+ "promise": "Core reader promise or question for this volume",
114
+ "keyTurns": ["Key turn 1", "Key turn 2"],
115
+ "midpoint": "Midpoint turn or changed understanding",
116
+ "climax": "Volume climax",
117
+ "payoffs": ["Threads or promises this volume plans to pay off"],
118
+ "lingeringMysteries": ["Mysteries intentionally left open at volume end"]
119
+ }
120
+ ],
109
121
  "chapters": [
110
122
  {
111
123
  "chapterNumber": 1,
@@ -121,50 +133,64 @@ Rules:
121
133
  - chapters.length must be at least ${input.state.targetChapters}.
122
134
  - chapterNumber must start at 1 and increase contiguously.
123
135
  - volumeId must reference an id from volumes.
136
+ - volumePacing must provide one pacing board for every volume.
124
137
  - requiredBeats must include at least one concrete, actionable beat.
125
138
  ${strictJsonOutputRules()}`,
126
139
  };
127
140
  }
128
141
 
129
142
  function buildChapterPrompt(input: PromptBuildInput): BuiltPrompt {
130
- return {
131
- purpose: 'chapter',
132
- expectedFormat: 'Markdown',
133
- prompt: `You are a professional long-form fiction writer. Write chapter ${input.state.currentChapter} directly.
143
+ const ch = input.state.currentChapter;
144
+ const isFirstChapter = ch <= 1;
145
+ return {
146
+ purpose: 'chapter',
147
+ expectedFormat: 'Markdown',
148
+ prompt: `You are a professional long-form fiction writer. Write chapter ${ch} directly.
134
149
 
135
150
  ## Priority Order
136
151
  1. Strictly follow the current chapter architecture, user additions, story bible hard constraints, and previous-chapter continuity.
137
- 2. Use relevant memory and prior text evidence to preserve consistency.
152
+ 2. Use relevant memory, prior text evidence, and active foreshadow threads.
138
153
  3. Treat full-book and volume plans as distant planning context only. Do not write concrete future events early.
139
154
 
140
- ## Style And Length
155
+ ## Length Target
156
+ - Default target: ~2500 words (±20%). If the chapter architecture specifies targetWords, follow it.
157
+ - Do not pad to hit the target; do not under-write to be brief at the cost of conflict.
158
+
159
+ ## Structure
160
+ ${isFirstChapter
161
+ ? '- This is chapter 1. No recap needed. Open with character and situation directly.'
162
+ : '- Start with a 2-3 sentence recap or bridge so a reader who skipped the last chapter can re-enter (unless the chapter architecture has requireRecap=false). Make it natural, not meta-narration like "previously...".'}
163
+ - The chapter must end on a clear hook: cliffhanger, mystery, emotional resonance, reveal, or volume close — per the chapter architecture endHookFocus. Default: cliffhanger.
164
+
165
+ ## Style
141
166
  - Match the novel's genre, world, character identities, and emotional tone.
142
- - Keep the language natural, stable, and readable. Prioritize narrative progress, character work, and emotional accumulation.
143
- - Dialogue must fit each character's identity, relationship, and immediate situation.
144
- - Important emotion should appear through action, body language, pacing, and subtext where possible.
145
- - Scene description should provide useful sensory and atmospheric detail, but never stall the plot.
146
- - Conflict, turns, suspense, and the chapter-end hook should be clear.
167
+ - Natural, stable, readable language; prioritize narrative progress, character work, and emotional accumulation.
168
+ - Dialogue fits each character's identity, relationship, and situation.
169
+ - Important emotion comes through action, body language, pacing, and subtext.
170
+ - Scene description has useful sensory detail without stalling.
171
+ - POV: strictly follow the chapter architecture povCharacter (if set). No mid-chapter POV switch.
147
172
 
148
173
  ## Execution Rules
149
174
  - Write only what the current chapter architecture authorizes.
150
- - Do not introduce unauthorized major characters. Functional background characters should stay light.
151
- - Keep names, items, places, abilities, timeline, injuries, relationships, and knowledge boundaries consistent.
152
- - If the previous chapter ends mid-action, mid-dialogue, or in the same scene, this chapter must continue from that point.
175
+ - Do not introduce unauthorized major characters. Functional background characters stay light.
176
+ - Keep names, items, places, abilities, timelines, injuries, relationships, and knowledge boundaries consistent.
177
+ - If the previous chapter ends mid-action or mid-scene, this chapter must continue from that point.
178
+ - Active foreshadow threads may be advanced or paid off this chapter, but **never silently dropped** — even if you choose not to touch them, leave them coherent.
153
179
  - Avoid cost-free power jumps, forced stupidity, mechanical twists, info-dumps, and empty lyricism.
154
- - Do not output summaries, bullet points, lectures, or explanatory prefaces.
180
+ - Do not output summaries, bullet points, lectures, explanatory prefaces, or meta-text like "what I changed".
155
181
 
156
182
  ${input.context ? `## Generation Context\n${input.context}\n` : ''}## Output Requirements
157
183
  - Output Markdown.
158
184
  - First line must be the chapter title as H1, for example: # Chapter Title
159
185
  - After the H1, begin the prose directly.`,
160
- };
186
+ };
161
187
  }
162
188
 
163
189
  function buildMemoryPrompt(input: PromptBuildInput): BuiltPrompt {
164
- return {
165
- purpose: 'memory_card',
166
- expectedFormat: 'JSON matching MemoryCardSchema',
167
- prompt: `You are a continuity editor for a long-form novel. Extract a memory card from chapter ${input.state.currentChapter}.
190
+ return {
191
+ purpose: 'memory_card',
192
+ expectedFormat: 'JSON matching MemoryCardSchema',
193
+ prompt: `You are a continuity editor for a long-form novel. Extract a memory card from chapter ${input.state.currentChapter}.
168
194
 
169
195
  ${input.context ? `## Current Chapter Context\n${input.context}\n` : ''}## Output Requirements
170
196
  Output valid JSON only, in this shape:
@@ -192,15 +218,43 @@ Output valid JSON only, in this shape:
192
218
  "after": "After"
193
219
  }
194
220
  ],
195
- "openThreads": ["Unresolved promise, danger, question, or plot thread"]
221
+ "openThreads": ["Unresolved promise, danger, question, or plot thread"],
222
+ "wordCount": <approximate word count of this chapter as an integer>,
223
+ "threadActions": [
224
+ {
225
+ "kind": "plant | build | pay | drop",
226
+ "threadId": "id of an existing active thread (required for build/pay/drop; leave empty for plant — the system will assign one)",
227
+ "description": "for plant: what the new thread is; for others: one sentence on how this chapter advanced/paid/dropped that thread"
228
+ }
229
+ ],
230
+ "characterUpdates": [
231
+ {
232
+ "name": "Character name",
233
+ "role": "Role if confirmed or changed this chapter",
234
+ "goal": "Current goal at chapter end",
235
+ "belief": "Core belief or understanding driving them at chapter end",
236
+ "relationships": [
237
+ { "name": "Related character", "dynamic": "Relationship state at chapter end" }
238
+ ],
239
+ "abilities": ["Abilities, resources, or limits confirmed at chapter end"],
240
+ "secrets": ["Secrets still hidden or only partially known at chapter end"],
241
+ "emotionalState": "Emotional state at chapter end"
242
+ }
243
+ ]
196
244
  }
197
245
 
198
246
  Rules:
199
247
  - Record only information that happened or was confirmed in this chapter.
200
248
  - Do not speculate about future plot.
201
249
  - Make facts and stateChanges concrete enough for later chapter reference.
250
+ - wordCount: approximate word count (English) or character count (CJK). An integer estimate is fine.
251
+ - threadActions is critical:
252
+ · For any active foreshadow thread in the context's "Active Foreshadow Threads" section, if this chapter advanced it emit kind="build"; if this chapter paid it off emit kind="pay"; if this chapter abandoned it emit kind="drop". threadId is required.
253
+ · For any new thread this chapter plants, emit kind="plant" with a clear description.
254
+ · If an active thread was not touched, no action needed — but never silently delete it. Without a drop action, the thread stays active.
255
+ - characterUpdates maintains a separate character state table. Emit only important characters whose state changed or was reconfirmed in this chapter; goal, belief, relationships, abilities, secrets, and emotionalState must reflect the chapter ending.
202
256
  ${strictJsonOutputRules()}`,
203
- };
257
+ };
204
258
  }
205
259
 
206
260
  function buildContinuityReviewPrompt(input: PromptBuildInput): BuiltPrompt {
@@ -250,6 +304,12 @@ return {
250
304
  prompt: `You are a strict editor reviewing a single chapter of a serial novel for in-chapter problems and conflicts with established context.
251
305
 
252
306
  ${input.context ? `## Review Context\n${input.context}\n` : ''}## Review Focus
307
+ - This is a mandatory chapter acceptance gate. If any acceptance item fails, status must be "issues_found" and the workflow must revise before continuing.
308
+ - Whether every requiredBeat is fulfilled; missing beats must appear in acceptance.requiredBeats.missingBeats.
309
+ - Whether this chapter advances the main line, character state, or active foreshadow threads. If it is static, at least one of narrativeProgress/characterProgress/foreshadowProgress must fail.
310
+ - Whether it violates the story bible, character state table, volume pacing board, or prior memory.
311
+ - Whether the ending has a clear hook that matches the chapter architecture endHookFocus.
312
+ - Whether it repeats prior chapter beats, conflict patterns, reveals, or dialogue functions.
253
313
  - Character voice, motivation, and state vs the story bible and prior memory.
254
314
  - World rules, item ownership, and ability limits.
255
315
  - Timeline, location, and continuity with the previous chapter ending.
@@ -261,6 +321,37 @@ Output valid JSON only, in this shape:
261
321
  {
262
322
  "chapterNumber": ${chapter},
263
323
  "status": "clean",
324
+ "acceptance": {
325
+ "requiredBeats": {
326
+ "status": "pass | fail",
327
+ "evidence": "Evidence for each requiredBeat",
328
+ "missingBeats": []
329
+ },
330
+ "narrativeProgress": {
331
+ "status": "pass | fail",
332
+ "evidence": "How this chapter advances the main line or phase objective"
333
+ },
334
+ "characterProgress": {
335
+ "status": "pass | fail",
336
+ "evidence": "How this chapter changes or confirms key character goal, belief, relationship, ability, secret, or emotion"
337
+ },
338
+ "foreshadowProgress": {
339
+ "status": "pass | fail",
340
+ "evidence": "How this chapter plants, advances, pays, or deliberately preserves foreshadow threads"
341
+ },
342
+ "storyBibleConsistency": {
343
+ "status": "pass | fail",
344
+ "evidence": "Whether it matches the story bible, character state table, and world rules"
345
+ },
346
+ "endingHook": {
347
+ "status": "pass | fail",
348
+ "evidence": "The ending hook passage and its function"
349
+ },
350
+ "repetition": {
351
+ "status": "pass | fail",
352
+ "evidence": "Whether it repeats prior beats; if not, explain why"
353
+ }
354
+ },
264
355
  "issues": [
265
356
  {
266
357
  "severity": "low | medium | high",
@@ -275,6 +366,8 @@ Output valid JSON only, in this shape:
275
366
  Rules:
276
367
  - If there are no issues, use status "clean" with an empty issues array.
277
368
  - Otherwise use status "issues_found".
369
+ - status may be "clean" only when every acceptance item is "pass".
370
+ - If any acceptance item is "fail", include a matching issue with a concrete fix.
278
371
  - evidence must be specific; do not write "possibly" or "maybe".
279
372
  ${strictJsonOutputRules()}`,
280
373
  };
@@ -361,6 +454,17 @@ function buildPromptForStep(input: PromptBuildInput): BuiltPrompt {
361
454
  return buildChapterRevisionPrompt(input);
362
455
  case 'cross_chapter_review':
363
456
  return buildCrossChapterReviewPrompt(input);
457
+ case 'story_bible_amend':
458
+ return {
459
+ purpose: 'story_bible',
460
+ expectedFormat: 'Markdown',
461
+ prompt: `Based on the current story bible and the amendment context, output the FULL revised story bible Markdown.
462
+
463
+ ${input.context ? `## Amendment Context\n${input.context}\n` : ''}## Output Requirements
464
+ - Output the entire story-bible.md content — it replaces the old one (old version auto-archived under story-bible-versions/).
465
+ - Preserve everything that still holds; modify / add / remove only what the amendment context justifies.
466
+ - Do not output diff markers, change logs, or bullet summaries — just the new full bible.`,
467
+ };
364
468
  case 'complete':
365
469
  return {
366
470
  purpose: 'continuity_review',
@@ -9,7 +9,8 @@ export type PromptPurpose =
9
9
  | 'continuity_review'
10
10
  | 'chapter_review'
11
11
  | 'chapter_revision'
12
- | 'cross_chapter_review';
12
+ | 'cross_chapter_review'
13
+ | 'story_bible_amend';
13
14
 
14
15
  export interface PromptBuildInput {
15
16
  state: AgentState;