novelforge-agent 0.1.1 → 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 (56) hide show
  1. package/README.md +45 -18
  2. package/dist/src/cli/index.js +81 -4
  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 +76 -1
  6. package/dist/src/core/fileNames.js +4 -0
  7. package/dist/src/core/index.js +4 -0
  8. package/dist/src/core/projectDiscovery.js +1 -0
  9. package/dist/src/core/projectOps.js +193 -0
  10. package/dist/src/core/projectStore.js +15 -1
  11. package/dist/src/core/prompts/en-US.js +247 -16
  12. package/dist/src/core/prompts/zh-CN.js +246 -15
  13. package/dist/src/core/retrieval/index.js +8 -0
  14. package/dist/src/core/schemas.js +121 -1
  15. package/dist/src/core/steps/architecture.js +7 -1
  16. package/dist/src/core/steps/architectureExtension.js +72 -0
  17. package/dist/src/core/steps/chapter.js +11 -1
  18. package/dist/src/core/steps/chapterReview.js +26 -1
  19. package/dist/src/core/steps/chapterRevision.js +17 -0
  20. package/dist/src/core/steps/index.js +4 -0
  21. package/dist/src/core/steps/memoryCard.js +26 -1
  22. package/dist/src/core/steps/novelMetadata.js +4 -2
  23. package/dist/src/core/steps/storyBible.js +1 -1
  24. package/dist/src/core/steps/styleGuide.js +12 -0
  25. package/dist/src/core/threadStore.js +150 -0
  26. package/dist/src/core/workflow.js +5 -3
  27. package/dist/src/mcp/tools.js +228 -20
  28. package/package.json +5 -1
  29. package/src/cli/index.ts +84 -3
  30. package/src/core/bibleStore.ts +57 -0
  31. package/src/core/characterStore.ts +93 -0
  32. package/src/core/contextBuilder.ts +74 -4
  33. package/src/core/fileNames.ts +5 -0
  34. package/src/core/index.ts +4 -0
  35. package/src/core/projectDiscovery.ts +2 -0
  36. package/src/core/projectOps.ts +251 -0
  37. package/src/core/projectStore.ts +19 -1
  38. package/src/core/prompts/en-US.ts +258 -25
  39. package/src/core/prompts/types.ts +4 -1
  40. package/src/core/prompts/zh-CN.ts +250 -17
  41. package/src/core/retrieval/index.ts +10 -0
  42. package/src/core/schemas.ts +133 -1
  43. package/src/core/steps/architecture.ts +7 -1
  44. package/src/core/steps/architectureExtension.ts +88 -0
  45. package/src/core/steps/chapter.ts +11 -1
  46. package/src/core/steps/chapterReview.ts +28 -1
  47. package/src/core/steps/chapterRevision.ts +18 -0
  48. package/src/core/steps/index.ts +4 -0
  49. package/src/core/steps/memoryCard.ts +27 -1
  50. package/src/core/steps/novelMetadata.ts +4 -2
  51. package/src/core/steps/storyBible.ts +1 -1
  52. package/src/core/steps/styleGuide.ts +13 -0
  53. package/src/core/threadStore.ts +173 -0
  54. package/src/core/types.ts +134 -1
  55. package/src/core/workflow.ts +5 -3
  56. package/src/mcp/tools.ts +351 -21
@@ -12,6 +12,16 @@ export const chapterHandler: StepHandler = async (state, content) => {
12
12
  return {
13
13
  savedPaths: [path],
14
14
  fileEntries: { [`chapter-${state.currentChapter}`]: relative },
15
- next: { kind: 'linear', nextStep: 'memory_card' },
15
+ next: {
16
+ kind: 'linear',
17
+ nextStep: 'chapter_review',
18
+ statePatch: {
19
+ pendingAction: {
20
+ step: 'chapter_review',
21
+ mode: 'gate',
22
+ chapterNumber: state.currentChapter,
23
+ },
24
+ },
25
+ },
16
26
  };
17
27
  };
@@ -6,12 +6,39 @@ import { StepHandler, parseJson } from './types.js';
6
6
 
7
7
  export const chapterReviewHandler: StepHandler = async (state, content) => {
8
8
  const parsed = ChapterReviewSchema.parse(parseJson(content));
9
+ const hasFailedAcceptance = Object.values(parsed.acceptance).some((check) => check.status === 'fail');
9
10
  const target = state.pendingAction?.chapterNumber ?? parsed.chapterNumber;
10
11
  const relative = join('reviews/chapter', chapterReviewFileName(target));
11
12
  const path = await saveJsonFile(state.projectPath, relative, parsed);
13
+ if (state.pendingAction?.mode === 'side_track') {
14
+ return {
15
+ savedPaths: [path],
16
+ fileEntries: { [`review-chapter-${target}`]: relative },
17
+ next: { kind: 'sideTrackReturn' },
18
+ };
19
+ }
20
+
21
+ if (parsed.status === 'clean' && !hasFailedAcceptance) {
22
+ return {
23
+ savedPaths: [path],
24
+ fileEntries: { [`review-chapter-${target}`]: relative },
25
+ next: { kind: 'linear', nextStep: 'memory_card' },
26
+ };
27
+ }
28
+
12
29
  return {
13
30
  savedPaths: [path],
14
31
  fileEntries: { [`review-chapter-${target}`]: relative },
15
- next: { kind: 'sideTrackReturn' },
32
+ next: {
33
+ kind: 'linear',
34
+ nextStep: 'chapter_revision',
35
+ statePatch: {
36
+ pendingAction: {
37
+ step: 'chapter_revision',
38
+ mode: 'gate',
39
+ chapterNumber: target,
40
+ },
41
+ },
42
+ },
16
43
  };
17
44
  };
@@ -13,6 +13,24 @@ export const chapterRevisionHandler: StepHandler = async (state, content) => {
13
13
  const savedPaths = archived ? [archived] : [];
14
14
  savedPaths.push(await saveMarkdownFile(state.projectPath, chapterRelative, content));
15
15
  await indexChapter(state.projectPath, target, content);
16
+ if (state.pendingAction?.mode === 'gate') {
17
+ return {
18
+ savedPaths,
19
+ fileEntries: { [`chapter-${target}`]: chapterRelative },
20
+ next: {
21
+ kind: 'linear',
22
+ nextStep: 'chapter_review',
23
+ statePatch: {
24
+ pendingAction: {
25
+ step: 'chapter_review',
26
+ mode: 'gate',
27
+ chapterNumber: target,
28
+ },
29
+ },
30
+ },
31
+ };
32
+ }
33
+
16
34
  return {
17
35
  savedPaths,
18
36
  fileEntries: { [`chapter-${target}`]: chapterRelative },
@@ -1,4 +1,5 @@
1
1
  import { WorkflowStep } from '../types.js';
2
+ import { architectureExtensionHandler } from './architectureExtension.js';
2
3
  import { architectureHandler } from './architecture.js';
3
4
  import { chapterHandler } from './chapter.js';
4
5
  import { chapterReviewHandler } from './chapterReview.js';
@@ -7,6 +8,7 @@ import { continuityReviewHandler } from './continuityReview.js';
7
8
  import { crossChapterReviewHandler } from './crossChapterReview.js';
8
9
  import { memoryCardHandler } from './memoryCard.js';
9
10
  import { novelMetadataHandler } from './novelMetadata.js';
11
+ import { styleGuideHandler } from './styleGuide.js';
10
12
  import { storyBibleHandler } from './storyBible.js';
11
13
  import { StepHandler } from './types.js';
12
14
 
@@ -15,7 +17,9 @@ export type { StepApplyNext, StepApplyResult, StepHandler } from './types.js';
15
17
  export const STEP_HANDLERS: Partial<Record<WorkflowStep, StepHandler>> = {
16
18
  novel_metadata: novelMetadataHandler,
17
19
  story_bible: storyBibleHandler,
20
+ style_guide: styleGuideHandler,
18
21
  architecture: architectureHandler,
22
+ architecture_extension: architectureExtensionHandler,
19
23
  chapter: chapterHandler,
20
24
  memory_card: memoryCardHandler,
21
25
  continuity_review: continuityReviewHandler,
@@ -1,22 +1,48 @@
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';
4
5
  import { memoryFileName } from '../fileNames.js';
5
6
  import { indexMemoryCard } from '../retrieval/index.js';
7
+ import { ingestMemoryCardThreads } from '../threadStore.js';
8
+ import { applyCharacterUpdates } from '../characterStore.js';
6
9
  import { StepHandler, parseJson } from './types.js';
7
10
 
11
+ async function maxPlannedChapter(projectPath: string): Promise<number> {
12
+ try {
13
+ const raw = await readFile(join(projectPath, 'architecture/chapters.json'), 'utf8');
14
+ const chapters = JSON.parse(raw) as Array<{ chapterNumber?: number }>;
15
+ return chapters.reduce((max, chapter) => {
16
+ const value = Number(chapter.chapterNumber);
17
+ return Number.isFinite(value) && value > max ? value : max;
18
+ }, 0);
19
+ } catch {
20
+ return 0;
21
+ }
22
+ }
23
+
8
24
  export const memoryCardHandler: StepHandler = async (state, content) => {
9
25
  const parsed = MemoryCardSchema.parse(parseJson(content));
10
26
  const relative = join('memory', memoryFileName(state.currentChapter));
11
27
  const path = await saveJsonFile(state.projectPath, relative, parsed);
12
28
  await indexMemoryCard(state.projectPath, state.currentChapter, parsed);
29
+ await ingestMemoryCardThreads(state.projectPath, state.currentChapter, parsed.threadActions);
30
+ await applyCharacterUpdates(state.projectPath, state.currentChapter, parsed.characterUpdates);
13
31
  const nextChapter = state.currentChapter + 1;
32
+ const plannedTotalChapters = state.plannedTotalChapters ?? state.targetChapters;
33
+ const plannedMax = await maxPlannedChapter(state.projectPath);
34
+ const nextStep =
35
+ nextChapter > plannedTotalChapters
36
+ ? 'continuity_review'
37
+ : nextChapter > plannedMax
38
+ ? 'architecture_extension'
39
+ : 'chapter';
14
40
  return {
15
41
  savedPaths: [path],
16
42
  fileEntries: { [`memory-${state.currentChapter}`]: relative },
17
43
  next: {
18
44
  kind: 'linear',
19
- nextStep: nextChapter > state.targetChapters ? 'continuity_review' : 'chapter',
45
+ nextStep,
20
46
  statePatch: { currentChapter: nextChapter },
21
47
  },
22
48
  };
@@ -1,13 +1,15 @@
1
1
  import { NovelMetadataSchema } from '../schemas.js';
2
2
  import { saveJsonFile } from '../projectStore.js';
3
+ import { initializeCharacterStates } from '../characterStore.js';
3
4
  import { StepHandler, parseJson } from './types.js';
4
5
 
5
6
  export const novelMetadataHandler: StepHandler = async (state, content) => {
6
7
  const parsed = NovelMetadataSchema.parse(parseJson(content));
7
8
  const path = await saveJsonFile(state.projectPath, 'novel.json', parsed);
9
+ const charactersPath = await initializeCharacterStates(state.projectPath, parsed.coreCast);
8
10
  return {
9
- savedPaths: [path],
10
- fileEntries: { novel: 'novel.json' },
11
+ savedPaths: [path, charactersPath],
12
+ fileEntries: { novel: 'novel.json', characters: 'characters.json' },
11
13
  next: { kind: 'linear', nextStep: 'story_bible' },
12
14
  };
13
15
  };
@@ -9,6 +9,6 @@ export const storyBibleHandler: StepHandler = async (state, content) => {
9
9
  return {
10
10
  savedPaths: [path],
11
11
  fileEntries: { storyBible: 'story-bible.md' },
12
- next: { kind: 'linear', nextStep: 'architecture' },
12
+ next: { kind: 'linear', nextStep: 'style_guide' },
13
13
  };
14
14
  };
@@ -0,0 +1,13 @@
1
+ import { StyleGuideSchema } from '../schemas.js';
2
+ import { saveJsonFile } from '../projectStore.js';
3
+ import { StepHandler, parseJson } from './types.js';
4
+
5
+ export const styleGuideHandler: StepHandler = async (state, content) => {
6
+ const parsed = StyleGuideSchema.parse(parseJson(content));
7
+ const path = await saveJsonFile(state.projectPath, 'style-guide.json', parsed);
8
+ return {
9
+ savedPaths: [path],
10
+ fileEntries: { styleGuide: 'style-guide.json' },
11
+ next: { kind: 'linear', nextStep: 'architecture' },
12
+ };
13
+ };
@@ -0,0 +1,173 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { readFile, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { Thread, ThreadAction, ThreadStatus } from './types.js';
5
+
6
+ const THREADS_FILE = 'threads.json';
7
+
8
+ export interface ThreadsBundle {
9
+ threads: Thread[];
10
+ }
11
+
12
+ export async function loadThreads(projectPath: string): Promise<Thread[]> {
13
+ try {
14
+ const raw = await readFile(join(projectPath, THREADS_FILE), 'utf8');
15
+ const parsed = JSON.parse(raw) as ThreadsBundle;
16
+ return Array.isArray(parsed.threads) ? parsed.threads : [];
17
+ } catch {
18
+ return [];
19
+ }
20
+ }
21
+
22
+ export async function saveThreads(projectPath: string, threads: Thread[]): Promise<string> {
23
+ const fullPath = join(projectPath, THREADS_FILE);
24
+ const bundle: ThreadsBundle = { threads };
25
+ await writeFile(fullPath, `${JSON.stringify(bundle, null, 2)}\n`, 'utf8');
26
+ return fullPath;
27
+ }
28
+
29
+ function newThreadId(existing: Set<string>): string {
30
+ let candidate = `t_${randomBytes(3).toString('hex')}`;
31
+ while (existing.has(candidate)) {
32
+ candidate = `t_${randomBytes(3).toString('hex')}`;
33
+ }
34
+ return candidate;
35
+ }
36
+
37
+ function findByDescription(threads: Thread[], description: string): Thread | undefined {
38
+ const target = description.trim();
39
+ return threads.find((t) => t.description.trim() === target);
40
+ }
41
+
42
+ /**
43
+ * Apply the threadActions emitted by a memory_card for chapter `chapterNumber`.
44
+ * Behavior:
45
+ * - 'plant' → create new thread (or reuse if identical description already planted)
46
+ * - 'build' → mark existing thread status = 'building', bump lastTouchedAt
47
+ * - 'pay' → mark existing thread status = 'paid', set paidOffAt
48
+ * - 'drop' → mark existing thread status = 'dropped', set droppedAt
49
+ * Unknown threadIds for non-plant actions are tolerated (a new thread is created and marked appropriately, so we never lose user intent).
50
+ */
51
+ export function applyThreadActions(
52
+ existing: Thread[],
53
+ chapterNumber: number,
54
+ actions: ThreadAction[]
55
+ ): Thread[] {
56
+ if (!actions || !actions.length) return existing;
57
+ const next: Thread[] = existing.map((t) => ({ ...t }));
58
+ const byId = new Map(next.map((t) => [t.id, t]));
59
+ const usedIds = new Set(next.map((t) => t.id));
60
+
61
+ for (const action of actions) {
62
+ if (action.kind === 'plant') {
63
+ const dup = findByDescription(next, action.description);
64
+ if (dup) {
65
+ dup.lastTouchedAt = Math.max(dup.lastTouchedAt, chapterNumber);
66
+ continue;
67
+ }
68
+ const id = action.threadId && !usedIds.has(action.threadId)
69
+ ? action.threadId
70
+ : newThreadId(usedIds);
71
+ usedIds.add(id);
72
+ const planted: Thread = {
73
+ id,
74
+ description: action.description.trim(),
75
+ status: 'planted',
76
+ plantedAt: chapterNumber,
77
+ lastTouchedAt: chapterNumber,
78
+ };
79
+ next.push(planted);
80
+ byId.set(id, planted);
81
+ continue;
82
+ }
83
+
84
+ // build / pay / drop need an existing thread
85
+ let target = action.threadId ? byId.get(action.threadId) : undefined;
86
+ if (!target) {
87
+ target = findByDescription(next, action.description);
88
+ }
89
+ if (!target) {
90
+ // Create a placeholder so the user intent is captured; mark planted+touched on this chapter
91
+ const id = newThreadId(usedIds);
92
+ usedIds.add(id);
93
+ target = {
94
+ id,
95
+ description: action.description.trim(),
96
+ status: 'planted',
97
+ plantedAt: chapterNumber,
98
+ lastTouchedAt: chapterNumber,
99
+ notes: `Auto-created from a ${action.kind} action without a known threadId.`,
100
+ };
101
+ next.push(target);
102
+ byId.set(id, target);
103
+ }
104
+
105
+ target.lastTouchedAt = chapterNumber;
106
+ if (action.kind === 'build') {
107
+ target.status = 'building';
108
+ } else if (action.kind === 'pay') {
109
+ target.status = 'paid';
110
+ target.paidOffAt = chapterNumber;
111
+ } else if (action.kind === 'drop') {
112
+ target.status = 'dropped';
113
+ target.droppedAt = chapterNumber;
114
+ }
115
+ }
116
+
117
+ return next;
118
+ }
119
+
120
+ export function activeThreads(threads: Thread[]): Thread[] {
121
+ return threads.filter((t) => t.status === 'planted' || t.status === 'building');
122
+ }
123
+
124
+ export async function ingestMemoryCardThreads(
125
+ projectPath: string,
126
+ chapterNumber: number,
127
+ actions: ThreadAction[] | undefined
128
+ ): Promise<Thread[]> {
129
+ if (!actions || !actions.length) return loadThreads(projectPath);
130
+ const existing = await loadThreads(projectPath);
131
+ const next = applyThreadActions(existing, chapterNumber, actions);
132
+ await saveThreads(projectPath, next);
133
+ return next;
134
+ }
135
+
136
+ export interface UpdateThreadPatch {
137
+ status?: ThreadStatus;
138
+ plannedPayoffAt?: number | null;
139
+ paidOffAt?: number | null;
140
+ droppedAt?: number | null;
141
+ description?: string;
142
+ notes?: string | null;
143
+ }
144
+
145
+ export async function updateThread(
146
+ projectPath: string,
147
+ id: string,
148
+ patch: UpdateThreadPatch
149
+ ): Promise<Thread> {
150
+ const existing = await loadThreads(projectPath);
151
+ const target = existing.find((t) => t.id === id);
152
+ if (!target) throw new Error(`Thread not found: ${id}`);
153
+ if (patch.status) target.status = patch.status;
154
+ if (patch.description) target.description = patch.description.trim();
155
+ if (Object.prototype.hasOwnProperty.call(patch, 'plannedPayoffAt')) {
156
+ if (patch.plannedPayoffAt === null) delete target.plannedPayoffAt;
157
+ else if (typeof patch.plannedPayoffAt === 'number') target.plannedPayoffAt = patch.plannedPayoffAt;
158
+ }
159
+ if (Object.prototype.hasOwnProperty.call(patch, 'paidOffAt')) {
160
+ if (patch.paidOffAt === null) delete target.paidOffAt;
161
+ else if (typeof patch.paidOffAt === 'number') target.paidOffAt = patch.paidOffAt;
162
+ }
163
+ if (Object.prototype.hasOwnProperty.call(patch, 'droppedAt')) {
164
+ if (patch.droppedAt === null) delete target.droppedAt;
165
+ else if (typeof patch.droppedAt === 'number') target.droppedAt = patch.droppedAt;
166
+ }
167
+ if (Object.prototype.hasOwnProperty.call(patch, 'notes')) {
168
+ if (patch.notes === null) delete target.notes;
169
+ else if (typeof patch.notes === 'string') target.notes = patch.notes;
170
+ }
171
+ await saveThreads(projectPath, existing);
172
+ return target;
173
+ }
package/src/core/types.ts CHANGED
@@ -1,28 +1,60 @@
1
1
  export type WorkflowStep =
2
2
  | 'novel_metadata'
3
3
  | 'story_bible'
4
+ | 'style_guide'
4
5
  | 'architecture'
6
+ | 'architecture_extension'
5
7
  | 'chapter'
6
8
  | 'memory_card'
7
9
  | 'continuity_review'
8
10
  | 'chapter_review'
9
11
  | 'chapter_revision'
10
12
  | 'cross_chapter_review'
13
+ | 'story_bible_amend'
11
14
  | 'complete';
12
15
 
13
16
  export type ReviewSeverity = 'low' | 'medium' | 'high';
14
17
 
15
18
  export interface ChapterReviewIssue {
16
19
  severity: ReviewSeverity;
17
- category: 'character' | 'world' | 'timeline' | 'item' | 'knowledge' | 'pacing' | 'style' | 'architecture';
20
+ category:
21
+ | 'character'
22
+ | 'world'
23
+ | 'timeline'
24
+ | 'item'
25
+ | 'knowledge'
26
+ | 'pacing'
27
+ | 'style'
28
+ | 'architecture'
29
+ | 'plot'
30
+ | 'foreshadow'
31
+ | 'hook'
32
+ | 'repetition';
18
33
  description: string;
19
34
  evidence: string;
20
35
  suggestion: string;
21
36
  }
22
37
 
38
+ export interface ChapterAcceptanceCheck {
39
+ status: 'pass' | 'fail';
40
+ evidence: string;
41
+ }
42
+
43
+ export interface ChapterAcceptanceGate {
44
+ requiredBeats: ChapterAcceptanceCheck & { missingBeats: string[] };
45
+ narrativeProgress: ChapterAcceptanceCheck;
46
+ characterProgress: ChapterAcceptanceCheck;
47
+ foreshadowProgress: ChapterAcceptanceCheck;
48
+ storyBibleConsistency: ChapterAcceptanceCheck;
49
+ proseRhythm: ChapterAcceptanceCheck;
50
+ endingHook: ChapterAcceptanceCheck;
51
+ repetition: ChapterAcceptanceCheck;
52
+ }
53
+
23
54
  export interface ChapterReview {
24
55
  chapterNumber: number;
25
56
  status: 'clean' | 'issues_found';
57
+ acceptance: ChapterAcceptanceGate;
26
58
  issues: ChapterReviewIssue[];
27
59
  }
28
60
 
@@ -53,6 +85,23 @@ export interface NovelMetadata {
53
85
  coreCast: CoreCastMember[];
54
86
  }
55
87
 
88
+ export interface StyleGuide {
89
+ narrativeVoice: string;
90
+ pacing: string;
91
+ diction: string;
92
+ dialogueRules: string[];
93
+ prohibitedPatterns: string[];
94
+ proseRhythm: {
95
+ sentenceRhythm: string;
96
+ paragraphing: string;
97
+ interiorityMode: string;
98
+ emphasisBudget: string;
99
+ antiPatterns: string[];
100
+ };
101
+ sampleParagraph: string;
102
+ consistencyChecks: string[];
103
+ }
104
+
56
105
  export interface VolumeArchitecture {
57
106
  id: string;
58
107
  title: string;
@@ -60,20 +109,95 @@ export interface VolumeArchitecture {
60
109
  order: number;
61
110
  }
62
111
 
112
+ export interface VolumePacingBoard {
113
+ volumeId: string;
114
+ start: string;
115
+ promise: string;
116
+ keyTurns: string[];
117
+ midpoint: string;
118
+ climax: string;
119
+ payoffs: string[];
120
+ lingeringMysteries: string[];
121
+ }
122
+
123
+ export type EndHookFocus = 'cliffhanger' | 'mystery' | 'emotional' | 'reveal' | 'volume_close' | 'gentle';
124
+
63
125
  export interface ChapterArchitecture {
64
126
  chapterNumber: number;
65
127
  title: string;
66
128
  volumeId: string;
67
129
  summary: string;
68
130
  requiredBeats: string[];
131
+ targetWords?: number;
132
+ requireRecap?: boolean;
133
+ endHookFocus?: EndHookFocus;
134
+ povCharacter?: string;
69
135
  }
70
136
 
71
137
  export interface ArchitecturePayload {
72
138
  full: string;
73
139
  volumes: VolumeArchitecture[];
140
+ volumePacing?: VolumePacingBoard[];
141
+ chapters: ChapterArchitecture[];
142
+ }
143
+
144
+ export interface ArchitectureExtensionPayload {
145
+ fullUpdate?: string;
146
+ volumes?: VolumeArchitecture[];
147
+ volumePacing?: VolumePacingBoard[];
74
148
  chapters: ChapterArchitecture[];
75
149
  }
76
150
 
151
+ export type ThreadStatus = 'planted' | 'building' | 'paid' | 'dropped';
152
+
153
+ export type ThreadActionKind = 'plant' | 'build' | 'pay' | 'drop';
154
+
155
+ export interface ThreadAction {
156
+ kind: ThreadActionKind;
157
+ threadId?: string; // existing thread id; required for build/pay/drop
158
+ description: string; // for plant: the new thread description; for others: how this chapter touched it
159
+ }
160
+
161
+ export interface Thread {
162
+ id: string;
163
+ description: string;
164
+ status: ThreadStatus;
165
+ plantedAt: number;
166
+ lastTouchedAt: number;
167
+ plannedPayoffAt?: number;
168
+ paidOffAt?: number;
169
+ droppedAt?: number;
170
+ notes?: string;
171
+ }
172
+
173
+ export interface CharacterRelationshipState {
174
+ name: string;
175
+ dynamic: string;
176
+ }
177
+
178
+ export interface CharacterState {
179
+ name: string;
180
+ role?: string;
181
+ goal: string;
182
+ belief: string;
183
+ relationships: CharacterRelationshipState[];
184
+ abilities: string[];
185
+ secrets: string[];
186
+ emotionalState: string;
187
+ lastUpdatedAt: number;
188
+ }
189
+
190
+ export interface CharacterStateUpdate {
191
+ name: string;
192
+ role?: string;
193
+ goal?: string;
194
+ belief?: string;
195
+ relationships?: CharacterRelationshipState[];
196
+ abilities?: string[];
197
+ secrets?: string[];
198
+ emotionalState?: string;
199
+ }
200
+
77
201
  export interface MemoryCard {
78
202
  summary: string;
79
203
  keyEvents: string[];
@@ -81,10 +205,14 @@ export interface MemoryCard {
81
205
  facts: Array<{ subject: string; predicate: string; object: string }>;
82
206
  stateChanges: Array<{ entity: string; before: string; after: string }>;
83
207
  openThreads: string[];
208
+ wordCount?: number;
209
+ threadActions?: ThreadAction[];
210
+ characterUpdates?: CharacterStateUpdate[];
84
211
  }
85
212
 
86
213
  export interface PendingAction {
87
214
  step: 'chapter_review' | 'chapter_revision' | 'cross_chapter_review';
215
+ mode?: 'side_track' | 'gate';
88
216
  chapterNumber?: number;
89
217
  range?: { start: number; end: number };
90
218
  feedback?: string;
@@ -95,7 +223,12 @@ export interface AgentState {
95
223
  projectPath: string;
96
224
  initialPrompt: string;
97
225
  language: 'zh-CN' | 'en-US';
226
+ /**
227
+ * Number of chapters to plan in each architecture batch.
228
+ * The whole-book target lives in plannedTotalChapters.
229
+ */
98
230
  targetChapters: number;
231
+ plannedTotalChapters: number;
99
232
  currentStep: WorkflowStep;
100
233
  currentChapter: number;
101
234
  completedSteps: WorkflowStep[];
@@ -37,6 +37,8 @@ export interface RequestSideTrackInput {
37
37
  type ContextRecipe = (state: AgentState) => Omit<BuildContextInput, 'projectPath'>;
38
38
 
39
39
  const CONTEXT_RECIPES: Partial<Record<WorkflowStep, ContextRecipe>> = {
40
+ style_guide: () => ({ purpose: 'style_guide' }),
41
+ architecture_extension: (s) => ({ purpose: 'architecture_extension', chapterNumber: s.currentChapter }),
40
42
  chapter: (s) => ({ purpose: 'chapter_generation', chapterNumber: s.currentChapter }),
41
43
  memory_card: (s) => ({ purpose: 'memory_extraction', chapterNumber: s.currentChapter }),
42
44
  continuity_review: () => ({ purpose: 'continuity_review' }),
@@ -132,17 +134,17 @@ function buildPendingAction(state: AgentState, input: RequestSideTrackInput): Pe
132
134
  switch (input.step) {
133
135
  case 'chapter_review': {
134
136
  if (!input.chapterNumber) throw new Error('chapter_review requires chapterNumber');
135
- return { step: 'chapter_review', chapterNumber: input.chapterNumber };
137
+ return { step: 'chapter_review', mode: 'side_track', chapterNumber: input.chapterNumber };
136
138
  }
137
139
  case 'chapter_revision': {
138
140
  if (!input.chapterNumber) throw new Error('chapter_revision requires chapterNumber');
139
- return { step: 'chapter_revision', chapterNumber: input.chapterNumber, feedback: input.feedback };
141
+ return { step: 'chapter_revision', mode: 'side_track', chapterNumber: input.chapterNumber, feedback: input.feedback };
140
142
  }
141
143
  case 'cross_chapter_review': {
142
144
  const max = maxExistingChapter(state);
143
145
  const range = input.range ?? { start: 1, end: max || state.currentChapter };
144
146
  if (range.start < 1 || range.end < range.start) throw new Error('Invalid range');
145
- return { step: 'cross_chapter_review', range };
147
+ return { step: 'cross_chapter_review', mode: 'side_track', range };
146
148
  }
147
149
  default:
148
150
  throw new Error(`Unknown side-track step: ${(input as RequestSideTrackInput).step}`);