@hanna84/mcp-writing 3.7.0 → 3.8.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.
@@ -27,8 +27,9 @@ export function registerReviewBundleTools(s, {
27
27
  project_id: z.string().describe("Project ID to scope the review bundle (e.g. 'test-novel')."),
28
28
  profile: z.enum(REVIEW_BUNDLE_PROFILES).describe("Bundle profile: outline_discussion, editor_detailed, or beta_reader_personalized."),
29
29
  part: z.number().int().optional().describe("Optional part filter."),
30
- chapter: z.number().int().optional().describe("Optional chapter filter."),
31
- chapters: z.array(z.number().int()).min(1).optional().describe("Optional chapter-set filter. Use this for one/few specific chapters. Do not combine with chapter."),
30
+ chapter: z.number().int().optional().describe("Optional compatibility chapter filter."),
31
+ chapter_id: z.string().optional().describe("Optional canonical chapter identifier."),
32
+ chapters: z.array(z.number().int()).min(1).optional().describe("Optional chapter-set filter. Use this for one/few specific chapters. Do not combine with chapter or chapter_id."),
32
33
  tag: z.string().optional().describe("Optional tag filter (exact match)."),
33
34
  scene_ids: z.array(z.string()).optional().describe("Optional explicit scene_id allowlist. Intersects with other filters."),
34
35
  strictness: z.enum(REVIEW_BUNDLE_STRICTNESS).optional().describe("Strictness mode: warn (default) or fail."),
@@ -47,6 +48,7 @@ export function registerReviewBundleTools(s, {
47
48
  profile,
48
49
  part,
49
50
  chapter,
51
+ chapter_id,
50
52
  chapters,
51
53
  tag,
52
54
  scene_ids,
@@ -72,6 +74,7 @@ export function registerReviewBundleTools(s, {
72
74
  profile,
73
75
  part,
74
76
  chapter,
77
+ chapter_id,
75
78
  chapters,
76
79
  tag,
77
80
  scene_ids,
@@ -121,8 +124,9 @@ export function registerReviewBundleTools(s, {
121
124
  profile: z.enum(REVIEW_BUNDLE_PROFILES).describe("Bundle profile: outline_discussion, editor_detailed, or beta_reader_personalized."),
122
125
  output_dir: z.string().describe("Directory path to write bundle artifacts into."),
123
126
  part: z.number().int().optional().describe("Optional part filter."),
124
- chapter: z.number().int().optional().describe("Optional chapter filter."),
125
- chapters: z.array(z.number().int()).min(1).optional().describe("Optional chapter-set filter. Use this for one/few specific chapters. Do not combine with chapter."),
127
+ chapter: z.number().int().optional().describe("Optional compatibility chapter filter."),
128
+ chapter_id: z.string().optional().describe("Optional canonical chapter identifier."),
129
+ chapters: z.array(z.number().int()).min(1).optional().describe("Optional chapter-set filter. Use this for one/few specific chapters. Do not combine with chapter or chapter_id."),
126
130
  tag: z.string().optional().describe("Optional tag filter (exact match)."),
127
131
  scene_ids: z.array(z.string()).optional().describe("Optional explicit scene_id allowlist. Intersects with other filters."),
128
132
  strictness: z.enum(REVIEW_BUNDLE_STRICTNESS).optional().describe("Strictness mode: warn (default) or fail."),
@@ -143,6 +147,7 @@ export function registerReviewBundleTools(s, {
143
147
  output_dir,
144
148
  part,
145
149
  chapter,
150
+ chapter_id,
146
151
  chapters,
147
152
  tag,
148
153
  scene_ids,
@@ -182,6 +187,7 @@ export function registerReviewBundleTools(s, {
182
187
  profile,
183
188
  part,
184
189
  chapter,
190
+ chapter_id,
185
191
  chapters,
186
192
  tag,
187
193
  scene_ids,
@@ -39,6 +39,67 @@ function readSceneEntityIdsFromMetadata({ scenePath, syncDir }) {
39
39
  };
40
40
  }
41
41
 
42
+ function resolveChapterByCompatibilityKey(db, { projectId, chapterNumber, chapterId }) {
43
+ if (!projectId) return null;
44
+ if (chapterId) {
45
+ return db.prepare(`
46
+ SELECT chapter_id, project_id, title, sort_index, logline, metadata_stale
47
+ FROM chapters
48
+ WHERE project_id = ? AND chapter_id = ?
49
+ `).get(projectId, chapterId);
50
+ }
51
+ if (chapterNumber == null) return null;
52
+ const canonicalChapter = db.prepare(`
53
+ SELECT chapter_id, project_id, title, sort_index, logline, metadata_stale
54
+ FROM chapters
55
+ WHERE project_id = ? AND sort_index = ?
56
+ `).get(projectId, chapterNumber);
57
+ if (canonicalChapter) return canonicalChapter;
58
+
59
+ return db.prepare(`
60
+ SELECT chapter_id, project_id, chapter_title AS title, chapter AS sort_index, NULL AS logline, MAX(metadata_stale) AS metadata_stale
61
+ FROM scenes
62
+ WHERE project_id = ? AND chapter = ? AND chapter_id IS NOT NULL
63
+ GROUP BY chapter_id, project_id, chapter_title, chapter
64
+ ORDER BY chapter_id
65
+ LIMIT 1
66
+ `).get(projectId, chapterNumber);
67
+ }
68
+
69
+ function resolveValidatedChapterFilter(db, { projectId, chapterNumber, chapterId }) {
70
+ if (!projectId) return { chapter: null };
71
+ if (!chapterId && chapterNumber == null) return { chapter: null };
72
+
73
+ const resolvedById = chapterId
74
+ ? resolveChapterByCompatibilityKey(db, { projectId, chapterId })
75
+ : null;
76
+ const resolvedByNumber = chapterNumber != null
77
+ ? resolveChapterByCompatibilityKey(db, { projectId, chapterNumber })
78
+ : null;
79
+
80
+ if (chapterId && chapterNumber != null) {
81
+ if (!resolvedById || !resolvedByNumber) {
82
+ return {
83
+ error: {
84
+ code: "NOT_FOUND",
85
+ message: "Chapter not found for the provided project and identifier.",
86
+ },
87
+ };
88
+ }
89
+ if (resolvedById.chapter_id !== resolvedByNumber.chapter_id) {
90
+ return {
91
+ error: {
92
+ code: "VALIDATION_ERROR",
93
+ message: "chapter_id and chapter must refer to the same canonical chapter when both are provided.",
94
+ },
95
+ };
96
+ }
97
+ return { chapter: resolvedById };
98
+ }
99
+
100
+ return { chapter: resolvedById ?? resolvedByNumber ?? null };
101
+ }
102
+
42
103
  function selectApplyCandidates(enrichedCandidates, selectedDocIds, maxApply) {
43
104
  const selectedSet = selectedDocIds ? new Set(selectedDocIds) : null;
44
105
  const chosenByDocId = new Map();
@@ -70,21 +131,43 @@ export function registerSearchTools(s, {
70
131
  // ---- find_scenes ---------------------------------------------------------
71
132
  s.tool(
72
133
  "find_scenes",
73
- "Find scenes by filtering on character, Save the Cat beat, tags, part, chapter, or POV. Returns ordered scene metadata only — no prose. All filters are optional and combinable. Supports pagination via page/page_size and auto-paginates large result sets with total_count. Warns if any matching scenes have stale metadata. Response shape note: always returns a structured envelope (`results`, `total_count`, with pagination fields when paging is active).",
134
+ "Find scenes by filtering on character, Save the Cat beat, tags, chapter identity, numeric compatibility chapter, or POV. Returns ordered scene metadata only — no prose. Most filters are optional and combinable. `chapter_id` requires `project_id`, and mixed `chapter_id`/`chapter` filters must resolve to the same canonical chapter. Supports pagination via page/page_size and auto-paginates large result sets with total_count. Warns if any matching scenes have stale metadata. Response shape note: always returns a structured envelope (`results`, `total_count`, with pagination fields when paging is active).",
74
135
  {
75
136
  project_id: z.string().optional().describe("Project ID (e.g. 'the-lamb'). Use to scope results to one project."),
76
137
  character: z.string().optional().describe("A character_id (e.g. 'char-mira-nystrom'). Returns only scenes that character appears in. Use list_characters first to find valid IDs."),
77
138
  beat: z.string().optional().describe("Save the Cat beat name (e.g. 'Opening Image'). Exact match."),
78
139
  tag: z.string().optional().describe("Scene tag to filter by. Exact match."),
79
140
  part: z.number().int().optional().describe("Part number (integer, e.g. 1). Chapters are numbered globally across the whole project."),
80
- chapter: z.number().int().optional().describe("Chapter number (integer, e.g. 3). Chapters are numbered globally across the whole project — do not reset per part."),
141
+ chapter: z.number().int().optional().describe("Compatibility chapter number resolved from canonical chapter sort order."),
142
+ chapter_id: z.string().optional().describe("Canonical chapter identifier. Requires project_id. Use list_chapters to find valid values."),
81
143
  pov: z.string().optional().describe("POV character_id. Use list_characters first to find valid IDs."),
82
144
  page: z.number().int().min(1).optional().describe("Optional page number for paginated responses (1-based)."),
83
145
  page_size: z.number().int().min(1).max(200).optional().describe("Optional page size for paginated responses (default: 20, max: 200)."),
84
146
  },
85
- async ({ project_id, character, beat, tag, part, chapter, pov, page, page_size }) => {
147
+ async ({ project_id, character, beat, tag, part, chapter, chapter_id, pov, page, page_size }) => {
148
+ if (chapter_id && !project_id) {
149
+ return errorResponse(
150
+ "VALIDATION_ERROR",
151
+ "Provide project_id when filtering by chapter_id.",
152
+ { chapter_id }
153
+ );
154
+ }
155
+ const resolvedChapterFilter = project_id && (chapter_id || chapter != null)
156
+ ? resolveValidatedChapterFilter(db, { projectId: project_id, chapterNumber: chapter, chapterId: chapter_id })
157
+ : { chapter: null };
158
+ if (resolvedChapterFilter.error) {
159
+ return errorResponse(
160
+ resolvedChapterFilter.error.code,
161
+ resolvedChapterFilter.error.message,
162
+ {
163
+ project_id: project_id ?? null,
164
+ chapter_id: chapter_id ?? null,
165
+ chapter: chapter ?? null,
166
+ }
167
+ );
168
+ }
86
169
  let query = `
87
- SELECT DISTINCT s.scene_id, s.project_id, s.title, s.part, s.chapter, s.chapter_title, s.pov,
170
+ SELECT DISTINCT s.scene_id, s.project_id, s.chapter_id, s.title, s.part, s.chapter, s.chapter_title, s.pov,
88
171
  s.logline, s.scene_change, s.causality, s.stakes, s.scene_functions,
89
172
  s.save_the_cat_beat, s.timeline_position, s.story_time,
90
173
  s.word_count, s.metadata_stale
@@ -105,12 +188,16 @@ export function registerSearchTools(s, {
105
188
  if (project_id) { conditions.push(`s.project_id = ?`); params.push(project_id); }
106
189
  if (beat) { conditions.push(`s.save_the_cat_beat = ?`); params.push(beat); }
107
190
  if (part) { conditions.push(`s.part = ?`); params.push(part); }
108
- if (chapter) { conditions.push(`s.chapter = ?`); params.push(chapter); }
191
+ if (resolvedChapterFilter.chapter) {
192
+ conditions.push(`s.chapter_id = ?`);
193
+ params.push(resolvedChapterFilter.chapter.chapter_id);
194
+ } else if (chapter_id) { conditions.push(`s.chapter_id = ?`); params.push(chapter_id); }
195
+ else if (chapter) { conditions.push(`s.chapter = ?`); params.push(chapter); }
109
196
  if (pov) { conditions.push(`s.pov = ?`); params.push(pov); }
110
197
 
111
198
  if (joins.length) query += " " + joins.join(" ");
112
199
  if (conditions.length) query += " WHERE " + conditions.join(" AND ");
113
- query += " ORDER BY s.part, s.chapter, s.timeline_position";
200
+ query += " ORDER BY s.part, s.chapter, s.timeline_position, s.scene_id";
114
201
 
115
202
  const rows = db.prepare(query).all(...params);
116
203
  if (rows.length === 0) {
@@ -229,21 +316,48 @@ export function registerSearchTools(s, {
229
316
  // ---- get_chapter_prose ---------------------------------------------------
230
317
  s.tool(
231
318
  "get_chapter_prose",
232
- `Load the full prose for every scene in a chapter, concatenated in order. Expensive — only use when you need to read an entire chapter. Capped at ${MAX_CHAPTER_SCENES} scenes. Use find_scenes first to confirm the chapter exists.`,
319
+ `Load the full prose for every scene in a chapter, concatenated in order. Provide chapter_id or chapter, plus project_id. Canonical targeting uses chapter_id; numeric chapter remains available as a compatibility alias resolved from canonical sort order. Expensive — only use when you need to read an entire chapter. Capped at ${MAX_CHAPTER_SCENES} scenes. Use find_scenes first to confirm the chapter exists.`,
233
320
  {
234
321
  project_id: z.string().describe("Project ID (e.g. 'the-lamb')."),
235
- part: z.number().int().describe("Part number (integer)."),
236
- chapter: z.number().int().describe("Chapter number (integer, globally numbered across the whole project)."),
322
+ chapter_id: z.string().optional().describe("Canonical chapter identifier."),
323
+ chapter: z.number().int().optional().describe("Compatibility chapter number resolved from canonical sort order."),
237
324
  },
238
- async ({ project_id, part, chapter }) => {
325
+ async ({ project_id, chapter_id, chapter }) => {
326
+ if (!chapter_id && chapter == null) {
327
+ return errorResponse("VALIDATION_ERROR", "Provide chapter_id or chapter.");
328
+ }
329
+ const resolvedChapterFilter = resolveValidatedChapterFilter(db, {
330
+ projectId: project_id,
331
+ chapterNumber: chapter,
332
+ chapterId: chapter_id,
333
+ });
334
+ if (resolvedChapterFilter.error) {
335
+ return errorResponse(
336
+ resolvedChapterFilter.error.code,
337
+ resolvedChapterFilter.error.message,
338
+ {
339
+ project_id,
340
+ chapter_id: chapter_id ?? null,
341
+ chapter: chapter ?? null,
342
+ }
343
+ );
344
+ }
345
+ const resolvedChapter = resolvedChapterFilter.chapter;
346
+ if (!resolvedChapter) {
347
+ return errorResponse("NOT_FOUND", "Chapter not found for the provided project and identifier.", {
348
+ project_id,
349
+ chapter_id: chapter_id ?? null,
350
+ chapter: chapter ?? null,
351
+ });
352
+ }
239
353
  const allScenes = db.prepare(`
240
354
  SELECT scene_id, title, file_path FROM scenes
241
- WHERE project_id = ? AND part = ? AND chapter = ?
242
- ORDER BY timeline_position
243
- `).all(project_id, part, chapter);
355
+ WHERE project_id = ? AND chapter_id = ?
356
+ ORDER BY timeline_position, scene_id
357
+ `).all(project_id, resolvedChapter.chapter_id);
244
358
 
245
359
  if (allScenes.length === 0) {
246
- return errorResponse("NO_RESULTS", `No scenes found for Part ${part}, Chapter ${chapter}.`);
360
+ return errorResponse("NO_RESULTS", `No scenes found for chapter '${resolvedChapter.title}'.`);
247
361
  }
248
362
 
249
363
  const truncated = allScenes.length > MAX_CHAPTER_SCENES;
@@ -266,6 +380,7 @@ export function registerSearchTools(s, {
266
380
  ...(truncated
267
381
  ? { warning: `Chapter has ${allScenes.length} scenes — only the first ${MAX_CHAPTER_SCENES} were loaded. Set MAX_CHAPTER_SCENES to increase this limit.` }
268
382
  : {}),
383
+ chapter: resolvedChapter,
269
384
  next_step: truncated
270
385
  ? "Narrow with find_scenes and inspect key scenes individually with get_scene_prose before expanding chapter scope."
271
386
  : "If you only need a subset, switch to find_scenes + get_scene_prose for tighter context control.",
@@ -274,6 +389,96 @@ export function registerSearchTools(s, {
274
389
  }
275
390
  );
276
391
 
392
+ // ---- list_chapters ------------------------------------------------------
393
+ s.tool(
394
+ "list_chapters",
395
+ "List canonical chapters for a project. Returns chapter_id plus compatibility sort order so callers can migrate from numeric chapter targeting without losing orientation.",
396
+ {
397
+ project_id: z.string().describe("Project ID."),
398
+ },
399
+ async ({ project_id }) => {
400
+ const rows = db.prepare(`
401
+ SELECT chapter_id, project_id, title, sort_index, logline, metadata_stale
402
+ FROM chapters
403
+ WHERE project_id = ?
404
+ ORDER BY sort_index, chapter_id
405
+ `).all(project_id);
406
+
407
+ if (rows.length === 0) {
408
+ return errorResponse("NO_RESULTS", `No chapters found for project '${project_id}'.`);
409
+ }
410
+
411
+ return {
412
+ content: [{
413
+ type: "text",
414
+ text: JSON.stringify({
415
+ results: rows,
416
+ total_count: rows.length,
417
+ }, null, 2),
418
+ }],
419
+ };
420
+ }
421
+ );
422
+
423
+ // ---- find_epigraphs -----------------------------------------------------
424
+ s.tool(
425
+ "find_epigraphs",
426
+ "List canonical epigraphs for a project, optionally narrowed to a canonical chapter_id or compatibility chapter number.",
427
+ {
428
+ project_id: z.string().describe("Project ID."),
429
+ chapter_id: z.string().optional().describe("Canonical chapter identifier."),
430
+ chapter: z.number().int().optional().describe("Compatibility chapter number resolved from canonical sort order."),
431
+ },
432
+ async ({ project_id, chapter_id, chapter }) => {
433
+ const resolvedChapterFilter = (chapter_id || chapter != null)
434
+ ? resolveValidatedChapterFilter(db, { projectId: project_id, chapterNumber: chapter, chapterId: chapter_id })
435
+ : { chapter: null };
436
+ if (resolvedChapterFilter.error) {
437
+ return errorResponse(
438
+ resolvedChapterFilter.error.code,
439
+ resolvedChapterFilter.error.message,
440
+ {
441
+ project_id,
442
+ chapter_id: chapter_id ?? null,
443
+ chapter: chapter ?? null,
444
+ }
445
+ );
446
+ }
447
+ const resolvedChapter = resolvedChapterFilter.chapter;
448
+ if ((chapter_id || chapter != null) && !resolvedChapter) {
449
+ return errorResponse("NOT_FOUND", "Chapter not found for the provided epigraph filter.", {
450
+ project_id,
451
+ chapter_id: chapter_id ?? null,
452
+ chapter: chapter ?? null,
453
+ });
454
+ }
455
+
456
+ const rows = db.prepare(`
457
+ SELECT e.epigraph_id, e.project_id, e.chapter_id, c.title AS chapter_title, c.sort_index AS chapter,
458
+ e.body, e.metadata_stale
459
+ FROM epigraphs e
460
+ JOIN chapters c ON c.chapter_id = e.chapter_id AND c.project_id = e.project_id
461
+ WHERE e.project_id = ?
462
+ AND (? IS NULL OR e.chapter_id = ?)
463
+ ORDER BY c.sort_index, e.epigraph_id
464
+ `).all(project_id, resolvedChapter?.chapter_id ?? null, resolvedChapter?.chapter_id ?? null);
465
+
466
+ if (rows.length === 0) {
467
+ return errorResponse("NO_RESULTS", `No epigraphs found for project '${project_id}'.`);
468
+ }
469
+
470
+ return {
471
+ content: [{
472
+ type: "text",
473
+ text: JSON.stringify({
474
+ results: rows,
475
+ total_count: rows.length,
476
+ }, null, 2),
477
+ }],
478
+ };
479
+ }
480
+ );
481
+
277
482
  // ---- get_arc -------------------------------------------------------------
278
483
  s.tool(
279
484
  "get_arc",
@@ -532,7 +737,7 @@ export function registerSearchTools(s, {
532
737
 
533
738
  if (!shouldPaginate) {
534
739
  const rows = db.prepare(`
535
- SELECT f.scene_id, f.project_id, s.title, s.logline, s.part, s.chapter, s.chapter_title, s.metadata_stale
740
+ SELECT f.scene_id, f.project_id, s.chapter_id, s.title, s.logline, s.part, s.chapter, s.chapter_title, s.metadata_stale
536
741
  FROM scenes_fts f
537
742
  JOIN scenes s ON s.scene_id = f.scene_id AND s.project_id = f.project_id
538
743
  WHERE scenes_fts MATCH ?
@@ -557,7 +762,7 @@ export function registerSearchTools(s, {
557
762
  const offset = (normalizedPage - 1) * safePageSize;
558
763
 
559
764
  const rows = db.prepare(`
560
- SELECT f.scene_id, f.project_id, s.title, s.logline, s.part, s.chapter, s.chapter_title, s.metadata_stale
765
+ SELECT f.scene_id, f.project_id, s.chapter_id, s.title, s.logline, s.part, s.chapter, s.chapter_title, s.metadata_stale
561
766
  FROM scenes_fts f
562
767
  JOIN scenes s ON s.scene_id = f.scene_id AND s.project_id = f.project_id
563
768
  WHERE scenes_fts MATCH ?
@@ -853,7 +1058,7 @@ export function registerSearchTools(s, {
853
1058
  st.beat AS thread_beat, s.timeline_position, s.story_time, s.metadata_stale
854
1059
  FROM scenes s
855
1060
  JOIN scene_threads st ON st.scene_id = s.scene_id AND st.project_id = s.project_id AND st.thread_id = ?
856
- ORDER BY s.part, s.chapter, s.timeline_position
1061
+ ORDER BY s.part, s.chapter, s.timeline_position, s.scene_id
857
1062
  `).all(thread_id);
858
1063
  const staleCount = rows.filter(r => r.metadata_stale).length;
859
1064
  const warning = staleCount > 0 ? `${staleCount} scene(s) have stale metadata.` : undefined;
@@ -899,11 +1104,12 @@ export function registerSearchTools(s, {
899
1104
  s.part, s.chapter, s.chapter_title, s.timeline_position, s.title AS scene_title
900
1105
  FROM character_relationships r
901
1106
  LEFT JOIN scenes s ON s.scene_id = r.scene_id
1107
+ AND (r.scene_id IS NULL OR s.project_id = COALESCE(?, s.project_id))
902
1108
  WHERE r.from_character = ? AND r.to_character = ?
903
1109
  `;
904
- const params = [from_character, to_character];
1110
+ const params = [project_id ?? null, from_character, to_character];
905
1111
  if (project_id) { query += ` AND (s.project_id = ? OR r.scene_id IS NULL)`; params.push(project_id); }
906
- query += ` ORDER BY s.part, s.chapter, s.timeline_position`;
1112
+ query += ` ORDER BY s.part, s.chapter, s.timeline_position, s.scene_id`;
907
1113
 
908
1114
  const rows = db.prepare(query).all(...params);
909
1115
  if (rows.length === 0) {
@@ -383,6 +383,7 @@ export function registerStyleguideTools(s, {
383
383
  scene_ids: z.array(z.string()).optional().describe("Optional scene_id allowlist to analyze."),
384
384
  part: z.number().int().optional().describe("Optional part filter."),
385
385
  chapter: z.number().int().optional().describe("Optional chapter filter."),
386
+ chapter_id: z.string().optional().describe("Optional canonical chapter identifier."),
386
387
  max_scenes: z.number().int().positive().optional().describe("Maximum number of scenes to analyze (default: 50)."),
387
388
  min_agreement: z.number().min(0).max(1).optional().describe("Minimum agreement ratio for suggested fields (default: 0.6)."),
388
389
  min_evidence: z.number().int().positive().optional().describe("Minimum number of observed scenes per field before suggesting it (default: 3)."),
@@ -393,6 +394,7 @@ export function registerStyleguideTools(s, {
393
394
  scene_ids,
394
395
  part,
395
396
  chapter,
397
+ chapter_id,
396
398
  max_scenes = 50,
397
399
  min_agreement = 0.6,
398
400
  min_evidence = 3,
@@ -408,6 +410,7 @@ export function registerStyleguideTools(s, {
408
410
  sceneIds: scene_ids,
409
411
  part,
410
412
  chapter,
413
+ chapterId: chapter_id,
411
414
  onlyStale: false,
412
415
  });
413
416
  if (!targetResolution.ok) {
@@ -419,7 +422,7 @@ export function registerStyleguideTools(s, {
419
422
  return errorResponse(
420
423
  "NOT_FOUND",
421
424
  `No scenes were found for project '${project_id}' with the requested filters.`,
422
- { project_id, scene_ids: scene_ids ?? null, part: part ?? null, chapter: chapter ?? null }
425
+ { project_id, scene_ids: scene_ids ?? null, part: part ?? null, chapter: chapter ?? null, chapter_id: chapter_id ?? null }
423
426
  );
424
427
  }
425
428
 
@@ -630,6 +633,7 @@ export function registerStyleguideTools(s, {
630
633
  scene_ids: z.array(z.string()).optional().describe("Optional scene_id allowlist to analyze."),
631
634
  part: z.number().int().optional().describe("Optional part filter."),
632
635
  chapter: z.number().int().optional().describe("Optional chapter filter."),
636
+ chapter_id: z.string().optional().describe("Optional canonical chapter identifier."),
633
637
  max_scenes: z.number().int().positive().optional().describe("Maximum number of scenes to analyze (default: 50)."),
634
638
  min_agreement: z.number().min(0).max(1).optional().describe("Minimum agreement ratio for suggested updates (default: 0.6)."),
635
639
  include_clean_scenes: z.boolean().optional().describe("If true, include scenes with no detected drift in scene_results."),
@@ -639,6 +643,7 @@ export function registerStyleguideTools(s, {
639
643
  scene_ids,
640
644
  part,
641
645
  chapter,
646
+ chapter_id,
642
647
  max_scenes = 50,
643
648
  min_agreement = 0.6,
644
649
  include_clean_scenes = false,
@@ -675,6 +680,7 @@ export function registerStyleguideTools(s, {
675
680
  sceneIds: scene_ids,
676
681
  part,
677
682
  chapter,
683
+ chapterId: chapter_id,
678
684
  onlyStale: false,
679
685
  });
680
686
  if (!targetResolution.ok) {
package/src/tools/sync.js CHANGED
@@ -334,6 +334,7 @@ export function registerSyncTools(s, {
334
334
  scene_ids: z.array(z.string()).optional().describe("Optional allowlist of scene IDs to process before other filters are applied."),
335
335
  part: z.number().int().optional().describe("Optional part number filter."),
336
336
  chapter: z.number().int().optional().describe("Optional chapter number filter."),
337
+ chapter_id: z.string().optional().describe("Optional canonical chapter identifier."),
337
338
  only_stale: z.boolean().optional().describe("If true, only process scenes currently marked metadata_stale."),
338
339
  dry_run: z.boolean().optional().describe("If true (default), returns preview results without writing sidecars."),
339
340
  replace_mode: z.enum(["merge", "replace"]).optional().describe("merge (default): add inferred IDs; replace: overwrite characters with inferred IDs."),
@@ -346,6 +347,7 @@ export function registerSyncTools(s, {
346
347
  scene_ids,
347
348
  part,
348
349
  chapter,
350
+ chapter_id,
349
351
  only_stale = false,
350
352
  dry_run = true,
351
353
  replace_mode = "merge",
@@ -386,6 +388,7 @@ export function registerSyncTools(s, {
386
388
  sceneIds: scene_ids,
387
389
  part,
388
390
  chapter,
391
+ chapterId: chapter_id,
389
392
  onlyStale: Boolean(only_stale),
390
393
  });
391
394
  if (!targetResolution.ok) {