@hanna84/mcp-writing 3.11.0 → 3.13.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.
@@ -4,7 +4,17 @@ import matter from "gray-matter";
4
4
  import { readMeta, writeMeta, indexSceneFile, applySceneStructurePatch } from "../sync/sync.js";
5
5
  import { validateProjectId, validateUniverseId } from "../sync/importer.js";
6
6
  import { resolveValidatedChapterFilter } from "../core/chapter-resolution.js";
7
- import { buildSceneChapterAssignmentPlan } from "../structure/scene-chapter-assignment.js";
7
+ import { buildMoveScenePlan, buildSceneChapterAssignmentPlan } from "../structure/scene-chapter-assignment.js";
8
+ import {
9
+ buildCreateChapterPlan,
10
+ buildRenameChapterPlan,
11
+ buildReorderChapterPlan,
12
+ buildAttachEpigraphPlan,
13
+ insertCanonicalChapter,
14
+ renameCanonicalChapter,
15
+ reorderCanonicalChapter,
16
+ attachCanonicalEpigraph,
17
+ } from "../structure/chapter-commands.js";
8
18
  import {
9
19
  persistSceneReferenceLink,
10
20
  upsertExplicitReferenceLinkRow,
@@ -81,6 +91,41 @@ function persistPlaceReferenceLink({ placePath, syncDir, targetDocId, relation }
81
91
  writeMeta(placePath, nextMeta);
82
92
  }
83
93
 
94
+ function writeStructureSidecarUpdates(updates, { failureCode }) {
95
+ const failures = [];
96
+ let updatedCount = 0;
97
+
98
+ for (const update of updates) {
99
+ try {
100
+ writeMeta(update.filePath, update.meta);
101
+ updatedCount += 1;
102
+ } catch (err) {
103
+ failures.push({
104
+ file_path: update.filePath,
105
+ message: err.message,
106
+ });
107
+ }
108
+ }
109
+
110
+ return {
111
+ updatedCount,
112
+ diagnostics: failures.length
113
+ ? [
114
+ {
115
+ code: failureCode,
116
+ severity: "warning",
117
+ message: "Canonical structure was updated, but one or more explicit sidecar compatibility updates failed.",
118
+ next_step: "Inspect the failed sidecar paths, then run sync and diagnose_structure before making more structure changes.",
119
+ details: {
120
+ failed_sidecar_count: failures.length,
121
+ failures,
122
+ },
123
+ },
124
+ ]
125
+ : [],
126
+ };
127
+ }
128
+
84
129
  function resolveProjectScopedSource({
85
130
  db,
86
131
  errorResponse,
@@ -511,6 +556,541 @@ export function registerMetadataTools(s, {
511
556
  }
512
557
  );
513
558
 
559
+ // ---- create_chapter ------------------------------------------------------
560
+ s.tool(
561
+ "create_chapter",
562
+ "Create a canonical chapter record through the explicit structure workflow. Writes canonical chapter state only; it does not create scene files, sidecars, or Scrivener-compatible folders. Use assign_scene_to_chapter afterward to place unchaptered scenes in the new chapter.",
563
+ {
564
+ project_id: z.string().describe("Project the chapter belongs to (e.g. 'the-lamb')."),
565
+ title: z.string().describe("Human-readable chapter title."),
566
+ sort_index: z.number().int().min(1).describe("Canonical chapter order within the project. Must be unused."),
567
+ chapter_id: z.string().optional().describe("Optional canonical chapter identifier. If omitted, one is derived from sort_index and title."),
568
+ logline: z.string().optional().describe("Optional chapter-level logline."),
569
+ },
570
+ async ({ project_id, title, sort_index, chapter_id, logline }) => {
571
+ if (!SYNC_DIR_WRITABLE) {
572
+ return errorResponse("READ_ONLY", "Cannot create chapter: sync dir is read-only.");
573
+ }
574
+
575
+ const projectIdCheck = validateProjectId(project_id);
576
+ if (!projectIdCheck.ok) {
577
+ return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
578
+ }
579
+
580
+ const plan = buildCreateChapterPlan(db, {
581
+ projectId: project_id,
582
+ title,
583
+ sortIndex: sort_index,
584
+ chapterId: chapter_id,
585
+ logline,
586
+ });
587
+ if (!plan.ok) {
588
+ return errorResponse(plan.error.code, plan.error.message, {
589
+ project_id,
590
+ title,
591
+ sort_index,
592
+ chapter_id: chapter_id ?? null,
593
+ ...(plan.error.details ?? {}),
594
+ });
595
+ }
596
+
597
+ try {
598
+ db.exec("BEGIN");
599
+ insertCanonicalChapter(db, plan.chapter);
600
+ db.exec("COMMIT");
601
+ } catch (err) {
602
+ try {
603
+ db.exec("ROLLBACK");
604
+ } catch (rollbackErr) {
605
+ void rollbackErr;
606
+ }
607
+ return errorResponse("IO_ERROR", `Failed to create chapter '${plan.chapter.chapter_id}': ${err.message}`);
608
+ }
609
+
610
+ return jsonResponse({
611
+ ok: true,
612
+ action: "created",
613
+ chapter: {
614
+ chapter_id: plan.chapter.chapter_id,
615
+ project_id: plan.chapter.project_id,
616
+ title: plan.chapter.title,
617
+ sort_index: plan.chapter.sort_index,
618
+ logline: plan.chapter.logline,
619
+ metadata_stale: plan.chapter.metadata_stale,
620
+ },
621
+ diagnostics: plan.diagnostics,
622
+ next_steps: [
623
+ "Use assign_scene_to_chapter to place unchaptered scenes in this chapter.",
624
+ "Run diagnose_structure if existing folders or sidecars may imply conflicting structure.",
625
+ ],
626
+ });
627
+ }
628
+ );
629
+
630
+ // ---- rename_chapter ------------------------------------------------------
631
+ s.tool(
632
+ "rename_chapter",
633
+ "Rename a canonical chapter through the explicit structure workflow. Updates canonical chapter state and explicit scene chapter_title compatibility fields; it does not rename scene files, sidecars by path-derived structure, or Scrivener-compatible folders.",
634
+ {
635
+ project_id: z.string().describe("Project the chapter belongs to (e.g. 'the-lamb')."),
636
+ chapter_id: z.string().describe("Canonical chapter identifier. Use list_chapters to find valid values."),
637
+ title: z.string().describe("New human-readable chapter title."),
638
+ },
639
+ async ({ project_id, chapter_id, title }) => {
640
+ if (!SYNC_DIR_WRITABLE) {
641
+ return errorResponse("READ_ONLY", "Cannot rename chapter: sync dir is read-only.");
642
+ }
643
+
644
+ const projectIdCheck = validateProjectId(project_id);
645
+ if (!projectIdCheck.ok) {
646
+ return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
647
+ }
648
+
649
+ const plan = buildRenameChapterPlan(db, {
650
+ projectId: project_id,
651
+ chapterId: chapter_id,
652
+ title,
653
+ });
654
+ if (!plan.ok) {
655
+ return errorResponse(plan.error.code, plan.error.message, {
656
+ project_id,
657
+ chapter_id,
658
+ title,
659
+ ...(plan.error.details ?? {}),
660
+ });
661
+ }
662
+
663
+ const linkedScenes = db.prepare(`
664
+ SELECT scene_id, project_id, file_path
665
+ FROM scenes
666
+ WHERE project_id = ? AND chapter_id = ?
667
+ ORDER BY scene_id
668
+ `).all(project_id, chapter_id);
669
+
670
+ const sidecarUpdates = [];
671
+ try {
672
+ for (const scene of linkedScenes) {
673
+ const { meta } = readMeta(scene.file_path, SYNC_DIR, { writable: true });
674
+ if (meta.chapter_id === chapter_id) {
675
+ sidecarUpdates.push({
676
+ scene,
677
+ filePath: scene.file_path,
678
+ meta: {
679
+ ...meta,
680
+ chapter_title: plan.chapter.title,
681
+ },
682
+ });
683
+ }
684
+ }
685
+
686
+ db.exec("BEGIN");
687
+ renameCanonicalChapter(db, plan.chapter);
688
+ db.exec("COMMIT");
689
+ } catch (err) {
690
+ try {
691
+ db.exec("ROLLBACK");
692
+ } catch (rollbackErr) {
693
+ void rollbackErr;
694
+ }
695
+ if (err.code === "ENOENT") {
696
+ return errorResponse("STALE_PATH", `Cannot rename chapter '${chapter_id}': an indexed scene file is missing. Run sync() to refresh.`, {
697
+ project_id,
698
+ chapter_id,
699
+ });
700
+ }
701
+ return errorResponse("IO_ERROR", `Failed to rename chapter '${chapter_id}': ${err.message}`);
702
+ }
703
+
704
+ const sidecarWriteResult = writeStructureSidecarUpdates(sidecarUpdates, {
705
+ failureCode: "SCENE_SIDECAR_UPDATE_FAILED",
706
+ });
707
+
708
+ return jsonResponse({
709
+ ok: true,
710
+ action: "renamed",
711
+ chapter: {
712
+ chapter_id: plan.chapter.chapter_id,
713
+ project_id: plan.chapter.project_id,
714
+ title: plan.chapter.title,
715
+ sort_index: plan.chapter.sort_index,
716
+ logline: plan.chapter.logline,
717
+ metadata_stale: plan.chapter.metadata_stale,
718
+ },
719
+ previous_title: plan.previousChapter.title,
720
+ updated_scene_count: linkedScenes.length,
721
+ updated_sidecar_count: sidecarWriteResult.updatedCount,
722
+ diagnostics: [
723
+ ...plan.diagnostics,
724
+ ...sidecarWriteResult.diagnostics,
725
+ ],
726
+ next_steps: [
727
+ "Use list_chapters to confirm the canonical title.",
728
+ "Run diagnose_structure if folder-derived structure may still use the previous chapter title.",
729
+ ],
730
+ });
731
+ }
732
+ );
733
+
734
+ // ---- reorder_chapter -----------------------------------------------------
735
+ s.tool(
736
+ "reorder_chapter",
737
+ "Reorder a canonical chapter through the explicit structure workflow. Updates canonical chapter order and explicit scene chapter/chapter_title compatibility fields; it does not rename, move, or resequence scene files, sidecars by path-derived structure, or Scrivener-compatible folders.",
738
+ {
739
+ project_id: z.string().describe("Project the chapter belongs to (e.g. 'the-lamb')."),
740
+ chapter_id: z.string().describe("Canonical chapter identifier. Use list_chapters to find valid values."),
741
+ sort_index: z.number().int().min(1).describe("New canonical chapter order within the project. Must be unused."),
742
+ },
743
+ async ({ project_id, chapter_id, sort_index }) => {
744
+ if (!SYNC_DIR_WRITABLE) {
745
+ return errorResponse("READ_ONLY", "Cannot reorder chapter: sync dir is read-only.");
746
+ }
747
+
748
+ const projectIdCheck = validateProjectId(project_id);
749
+ if (!projectIdCheck.ok) {
750
+ return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
751
+ }
752
+
753
+ const plan = buildReorderChapterPlan(db, {
754
+ projectId: project_id,
755
+ chapterId: chapter_id,
756
+ sortIndex: sort_index,
757
+ });
758
+ if (!plan.ok) {
759
+ return errorResponse(plan.error.code, plan.error.message, {
760
+ project_id,
761
+ chapter_id,
762
+ sort_index,
763
+ ...(plan.error.details ?? {}),
764
+ });
765
+ }
766
+
767
+ const linkedScenes = db.prepare(`
768
+ SELECT scene_id, project_id, file_path
769
+ FROM scenes
770
+ WHERE project_id = ? AND chapter_id = ?
771
+ ORDER BY scene_id
772
+ `).all(project_id, chapter_id);
773
+
774
+ const sidecarUpdates = [];
775
+ try {
776
+ for (const scene of linkedScenes) {
777
+ const { meta } = readMeta(scene.file_path, SYNC_DIR, { writable: true });
778
+ if (meta.chapter_id === chapter_id) {
779
+ sidecarUpdates.push({
780
+ scene,
781
+ filePath: scene.file_path,
782
+ meta: {
783
+ ...meta,
784
+ chapter: plan.chapter.sort_index,
785
+ chapter_title: plan.chapter.title,
786
+ },
787
+ });
788
+ }
789
+ }
790
+
791
+ db.exec("BEGIN");
792
+ reorderCanonicalChapter(db, plan.chapter);
793
+ db.exec("COMMIT");
794
+ } catch (err) {
795
+ try {
796
+ db.exec("ROLLBACK");
797
+ } catch (rollbackErr) {
798
+ void rollbackErr;
799
+ }
800
+ if (err.code === "ENOENT") {
801
+ return errorResponse("STALE_PATH", `Cannot reorder chapter '${chapter_id}': an indexed scene file is missing. Run sync() to refresh.`, {
802
+ project_id,
803
+ chapter_id,
804
+ });
805
+ }
806
+ return errorResponse("IO_ERROR", `Failed to reorder chapter '${chapter_id}': ${err.message}`);
807
+ }
808
+
809
+ const sidecarWriteResult = writeStructureSidecarUpdates(sidecarUpdates, {
810
+ failureCode: "SCENE_SIDECAR_UPDATE_FAILED",
811
+ });
812
+
813
+ return jsonResponse({
814
+ ok: true,
815
+ action: "reordered",
816
+ chapter: {
817
+ chapter_id: plan.chapter.chapter_id,
818
+ project_id: plan.chapter.project_id,
819
+ title: plan.chapter.title,
820
+ sort_index: plan.chapter.sort_index,
821
+ logline: plan.chapter.logline,
822
+ metadata_stale: plan.chapter.metadata_stale,
823
+ },
824
+ previous_sort_index: plan.previousChapter.sort_index,
825
+ updated_scene_count: linkedScenes.length,
826
+ updated_sidecar_count: sidecarWriteResult.updatedCount,
827
+ diagnostics: [
828
+ ...plan.diagnostics,
829
+ ...sidecarWriteResult.diagnostics,
830
+ ],
831
+ next_steps: [
832
+ "Use list_chapters to confirm canonical order.",
833
+ "Run diagnose_structure if folder-derived structure may still use the previous order.",
834
+ ],
835
+ });
836
+ }
837
+ );
838
+
839
+ // ---- attach_epigraph -----------------------------------------------------
840
+ s.tool(
841
+ "attach_epigraph",
842
+ "Attach an existing canonical epigraph to a canonical chapter through the explicit structure workflow. Updates canonical epigraph linkage and explicit epigraph sidecar fields; it does not move, rename, or create epigraph source files or Scrivener-compatible folders.",
843
+ {
844
+ project_id: z.string().describe("Project the epigraph belongs to (e.g. 'the-lamb')."),
845
+ epigraph_id: z.string().describe("Canonical epigraph identifier. Use find_epigraphs to find valid values."),
846
+ chapter_id: z.string().describe("Canonical chapter identifier. Use list_chapters to find valid values."),
847
+ },
848
+ async ({ project_id, epigraph_id, chapter_id }) => {
849
+ if (!SYNC_DIR_WRITABLE) {
850
+ return errorResponse("READ_ONLY", "Cannot attach epigraph: sync dir is read-only.");
851
+ }
852
+
853
+ const projectIdCheck = validateProjectId(project_id);
854
+ if (!projectIdCheck.ok) {
855
+ return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
856
+ }
857
+
858
+ const plan = buildAttachEpigraphPlan(db, {
859
+ projectId: project_id,
860
+ epigraphId: epigraph_id,
861
+ chapterId: chapter_id,
862
+ });
863
+ if (!plan.ok) {
864
+ return errorResponse(plan.error.code, plan.error.message, {
865
+ project_id,
866
+ epigraph_id,
867
+ chapter_id,
868
+ ...(plan.error.details ?? {}),
869
+ });
870
+ }
871
+
872
+ try {
873
+ const { meta } = readMeta(plan.epigraph.file_path, SYNC_DIR, { writable: true });
874
+ const sidecarUpdate = {
875
+ filePath: plan.epigraph.file_path,
876
+ meta: {
877
+ ...meta,
878
+ kind: meta.kind ?? "epigraph",
879
+ epigraph_id: plan.epigraph.epigraph_id,
880
+ chapter_id: plan.chapter.chapter_id,
881
+ chapter: plan.chapter.sort_index,
882
+ chapter_title: plan.chapter.title,
883
+ },
884
+ };
885
+
886
+ db.exec("BEGIN");
887
+ attachCanonicalEpigraph(db, plan.epigraph);
888
+ db.exec("COMMIT");
889
+
890
+ const sidecarWriteResult = writeStructureSidecarUpdates([sidecarUpdate], {
891
+ failureCode: "EPIGRAPH_SIDECAR_UPDATE_FAILED",
892
+ });
893
+
894
+ return jsonResponse({
895
+ ok: true,
896
+ action: "attached",
897
+ epigraph: {
898
+ epigraph_id: plan.epigraph.epigraph_id,
899
+ project_id: plan.epigraph.project_id,
900
+ chapter_id: plan.epigraph.chapter_id,
901
+ metadata_stale: plan.epigraph.metadata_stale,
902
+ },
903
+ chapter: {
904
+ chapter_id: plan.chapter.chapter_id,
905
+ title: plan.chapter.title,
906
+ sort_index: plan.chapter.sort_index,
907
+ },
908
+ previous_chapter: plan.previousChapter
909
+ ? {
910
+ chapter_id: plan.previousChapter.chapter_id,
911
+ title: plan.previousChapter.title,
912
+ sort_index: plan.previousChapter.sort_index,
913
+ }
914
+ : null,
915
+ updated_sidecar_count: sidecarWriteResult.updatedCount,
916
+ diagnostics: [
917
+ ...plan.diagnostics,
918
+ ...sidecarWriteResult.diagnostics,
919
+ ],
920
+ next_steps: [
921
+ "Use find_epigraphs to confirm the canonical epigraph attachment.",
922
+ "Run diagnose_structure if folder-derived structure may still imply the previous chapter.",
923
+ ],
924
+ });
925
+ } catch (err) {
926
+ try {
927
+ db.exec("ROLLBACK");
928
+ } catch (rollbackErr) {
929
+ void rollbackErr;
930
+ }
931
+ if (err.code === "ENOENT") {
932
+ return errorResponse("STALE_PATH", `Cannot attach epigraph '${epigraph_id}': the indexed epigraph file is missing. Run sync() to refresh.`, {
933
+ project_id,
934
+ epigraph_id,
935
+ chapter_id,
936
+ });
937
+ }
938
+ return errorResponse("IO_ERROR", `Failed to attach epigraph '${epigraph_id}': ${err.message}`);
939
+ }
940
+ }
941
+ );
942
+
943
+ // ---- move_scene ----------------------------------------------------------
944
+ s.tool(
945
+ "move_scene",
946
+ "Move a scene through the explicit structure workflow. Updates canonical chapter linkage and/or timeline_position in the scene sidecar and index; it does not move, rename, or resequence scene files or Scrivener-compatible folders.",
947
+ {
948
+ scene_id: z.string().describe("The scene_id to move (e.g. 'sc-011-sebastian')."),
949
+ project_id: z.string().describe("Project the scene belongs to (e.g. 'the-lamb')."),
950
+ chapter_id: z.string().optional().describe("Optional canonical chapter identifier. Use list_chapters to find valid values. Omit to keep the current chapter."),
951
+ timeline_position: z.number().int().min(1).optional().describe("Optional new position within the target chapter. Must be unused."),
952
+ },
953
+ async ({ scene_id, project_id, chapter_id, timeline_position }) => {
954
+ if (!SYNC_DIR_WRITABLE) {
955
+ return errorResponse("READ_ONLY", "Cannot move scene: sync dir is read-only.");
956
+ }
957
+
958
+ const projectIdCheck = validateProjectId(project_id);
959
+ if (!projectIdCheck.ok) {
960
+ return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
961
+ }
962
+
963
+ if (chapter_id === undefined && timeline_position === undefined) {
964
+ return errorResponse("VALIDATION_ERROR", "Provide chapter_id and/or timeline_position for move_scene.", {
965
+ project_id,
966
+ scene_id,
967
+ });
968
+ }
969
+
970
+ const scene = db.prepare(`
971
+ SELECT scene_id, project_id, chapter_id, chapter, chapter_title, timeline_position, file_path
972
+ FROM scenes
973
+ WHERE scene_id = ? AND project_id = ?
974
+ `).get(scene_id, project_id);
975
+ if (!scene) {
976
+ return errorResponse("NOT_FOUND", `Scene '${scene_id}' not found in project '${project_id}'.`);
977
+ }
978
+
979
+ let chapter = undefined;
980
+ if (chapter_id !== undefined) {
981
+ const resolvedChapterFilter = resolveValidatedChapterFilter(db, {
982
+ projectId: project_id,
983
+ chapterId: chapter_id,
984
+ });
985
+
986
+ if (resolvedChapterFilter.error) {
987
+ return errorResponse(
988
+ resolvedChapterFilter.error.code,
989
+ resolvedChapterFilter.error.message,
990
+ { project_id, chapter_id }
991
+ );
992
+ }
993
+
994
+ chapter = resolvedChapterFilter.chapter;
995
+ if (!chapter) {
996
+ return errorResponse("NOT_FOUND", "Chapter not found for the provided project and identifier.", {
997
+ project_id,
998
+ chapter_id,
999
+ });
1000
+ }
1001
+ }
1002
+
1003
+ try {
1004
+ const { meta } = readMeta(scene.file_path, SYNC_DIR, { writable: true });
1005
+ const plan = buildMoveScenePlan(SYNC_DIR, scene.file_path, meta, {
1006
+ currentScene: scene,
1007
+ chapter,
1008
+ timelinePosition: timeline_position,
1009
+ });
1010
+ if (!plan.ok) {
1011
+ return errorResponse(plan.error.code, plan.error.message, {
1012
+ project_id,
1013
+ scene_id,
1014
+ chapter_id: chapter_id ?? null,
1015
+ timeline_position: timeline_position ?? null,
1016
+ ...(plan.error.details ?? {}),
1017
+ });
1018
+ }
1019
+
1020
+ const targetChapterId = plan.meta.chapter_id ?? null;
1021
+ const effectiveTimelinePosition = plan.timelinePosition;
1022
+ const targetChapterChanged = chapter_id !== undefined
1023
+ && (plan.previousChapterId ?? null) !== targetChapterId;
1024
+ if (effectiveTimelinePosition != null && (timeline_position !== undefined || targetChapterChanged)) {
1025
+ const positionConflict = targetChapterId === null
1026
+ ? db.prepare(`
1027
+ SELECT scene_id
1028
+ FROM scenes
1029
+ WHERE project_id = ? AND chapter_id IS NULL AND timeline_position = ? AND scene_id != ?
1030
+ ORDER BY scene_id
1031
+ LIMIT 1
1032
+ `).get(project_id, effectiveTimelinePosition, scene_id)
1033
+ : db.prepare(`
1034
+ SELECT scene_id
1035
+ FROM scenes
1036
+ WHERE project_id = ? AND chapter_id = ? AND timeline_position = ? AND scene_id != ?
1037
+ ORDER BY scene_id
1038
+ LIMIT 1
1039
+ `).get(project_id, targetChapterId, effectiveTimelinePosition, scene_id);
1040
+
1041
+ if (positionConflict) {
1042
+ return errorResponse("VALIDATION_ERROR", `timeline_position ${effectiveTimelinePosition} is already used in the target chapter.`, {
1043
+ project_id,
1044
+ scene_id,
1045
+ chapter_id: targetChapterId,
1046
+ timeline_position: effectiveTimelinePosition,
1047
+ existing_scene_id: positionConflict.scene_id,
1048
+ next_step: "Choose an unused timeline_position. Automatic resequencing is not part of this command yet.",
1049
+ });
1050
+ }
1051
+ }
1052
+
1053
+ writeMeta(scene.file_path, plan.meta);
1054
+
1055
+ const { content: prose } = matter(fs.readFileSync(scene.file_path, "utf8"));
1056
+ indexSceneFile(db, SYNC_DIR, scene.file_path, plan.meta, prose);
1057
+
1058
+ return jsonResponse({
1059
+ ok: true,
1060
+ action: "moved",
1061
+ scene_id,
1062
+ project_id,
1063
+ previous_chapter_id: plan.previousChapterId,
1064
+ previous_timeline_position: plan.previousTimelinePosition,
1065
+ chapter: plan.assignedChapter,
1066
+ timeline_position: plan.timelinePosition,
1067
+ diagnostics: [
1068
+ {
1069
+ code: "REPRESENTATION_NOT_MOVED",
1070
+ severity: "warning",
1071
+ message: "Moved canonical scene structure fields only; the existing scene source file was not moved or renamed.",
1072
+ next_step: "Run diagnose_structure if folder-derived structure may still imply the previous placement.",
1073
+ details: {
1074
+ file_path: scene.file_path,
1075
+ },
1076
+ },
1077
+ ],
1078
+ next_steps: [
1079
+ "Use find_scenes to confirm the scene's canonical chapter and timeline_position.",
1080
+ "Run diagnose_structure if folder-derived structure may still imply the previous placement.",
1081
+ ],
1082
+ });
1083
+ } catch (err) {
1084
+ if (err.code === "ENOENT") {
1085
+ return errorResponse("STALE_PATH", `Prose file for scene '${scene_id}' not found at indexed path. Run sync() to refresh.`, {
1086
+ indexed_path: scene.file_path,
1087
+ });
1088
+ }
1089
+ return errorResponse("IO_ERROR", `Failed to move scene '${scene_id}': ${err.message}`);
1090
+ }
1091
+ }
1092
+ );
1093
+
514
1094
  // ---- assign_scene_to_chapter --------------------------------------------
515
1095
  s.tool(
516
1096
  "assign_scene_to_chapter",
@@ -27,9 +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 compatibility chapter filter."),
30
+ chapter: z.number().int().optional().describe("Optional compatibility chapter number resolved through canonical chapter identity."),
31
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
+ chapters: z.array(z.number().int()).min(1).optional().describe("Optional compatibility chapter-set filter resolved through canonical chapter identities. Use this for one/few specific chapters. Do not combine with chapter or chapter_id."),
33
33
  tag: z.string().optional().describe("Optional tag filter (exact match)."),
34
34
  scene_ids: z.array(z.string()).optional().describe("Optional explicit scene_id allowlist. Intersects with other filters."),
35
35
  strictness: z.enum(REVIEW_BUNDLE_STRICTNESS).optional().describe("Strictness mode: warn (default) or fail."),
@@ -124,9 +124,9 @@ export function registerReviewBundleTools(s, {
124
124
  profile: z.enum(REVIEW_BUNDLE_PROFILES).describe("Bundle profile: outline_discussion, editor_detailed, or beta_reader_personalized."),
125
125
  output_dir: z.string().describe("Directory path to write bundle artifacts into."),
126
126
  part: z.number().int().optional().describe("Optional part filter."),
127
- chapter: z.number().int().optional().describe("Optional compatibility chapter filter."),
127
+ chapter: z.number().int().optional().describe("Optional compatibility chapter number resolved through canonical chapter identity."),
128
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."),
129
+ chapters: z.array(z.number().int()).min(1).optional().describe("Optional compatibility chapter-set filter resolved through canonical chapter identities. Use this for one/few specific chapters. Do not combine with chapter or chapter_id."),
130
130
  tag: z.string().optional().describe("Optional tag filter (exact match)."),
131
131
  scene_ids: z.array(z.string()).optional().describe("Optional explicit scene_id allowlist. Intersects with other filters."),
132
132
  strictness: z.enum(REVIEW_BUNDLE_STRICTNESS).optional().describe("Strictness mode: warn (default) or fail."),
@@ -131,8 +131,10 @@ export function registerSearchTools(s, {
131
131
  if (resolvedChapterFilter.chapter) {
132
132
  conditions.push(`s.chapter_id = ?`);
133
133
  params.push(resolvedChapterFilter.chapter.chapter_id);
134
- } else if (chapter_id) { conditions.push(`s.chapter_id = ?`); params.push(chapter_id); }
135
- else if (chapter) { conditions.push(`s.chapter = ?`); params.push(chapter); }
134
+ } else if (chapter != null && !project_id) {
135
+ conditions.push(`s.chapter = ?`);
136
+ params.push(chapter);
137
+ }
136
138
  if (pov) { conditions.push(`s.pov = ?`); params.push(pov); }
137
139
 
138
140
  if (joins.length) query += " " + joins.join(" ");
@@ -382,7 +382,7 @@ export function registerStyleguideTools(s, {
382
382
  project_id: z.string().describe("Project ID to analyze (e.g. 'the-lamb' or 'universe-1/book-1')."),
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
- chapter: z.number().int().optional().describe("Optional chapter filter."),
385
+ chapter: z.number().int().optional().describe("Optional compatibility chapter number resolved through canonical chapter identity."),
386
386
  chapter_id: z.string().optional().describe("Optional canonical chapter identifier."),
387
387
  max_scenes: z.number().int().positive().optional().describe("Maximum number of scenes to analyze (default: 50)."),
388
388
  min_agreement: z.number().min(0).max(1).optional().describe("Minimum agreement ratio for suggested fields (default: 0.6)."),
@@ -632,7 +632,7 @@ export function registerStyleguideTools(s, {
632
632
  project_id: z.string().describe("Project ID to analyze (e.g. 'the-lamb' or 'universe-1/book-1')."),
633
633
  scene_ids: z.array(z.string()).optional().describe("Optional scene_id allowlist to analyze."),
634
634
  part: z.number().int().optional().describe("Optional part filter."),
635
- chapter: z.number().int().optional().describe("Optional chapter filter."),
635
+ chapter: z.number().int().optional().describe("Optional compatibility chapter number resolved through canonical chapter identity."),
636
636
  chapter_id: z.string().optional().describe("Optional canonical chapter identifier."),
637
637
  max_scenes: z.number().int().positive().optional().describe("Maximum number of scenes to analyze (default: 50)."),
638
638
  min_agreement: z.number().min(0).max(1).optional().describe("Minimum agreement ratio for suggested updates (default: 0.6)."),
package/src/tools/sync.js CHANGED
@@ -355,7 +355,7 @@ export function registerSyncTools(s, {
355
355
  project_id: z.string().describe("Project ID (e.g. 'the-lamb' or 'universe-1/book-1-the-lamb')."),
356
356
  scene_ids: z.array(z.string()).optional().describe("Optional allowlist of scene IDs to process before other filters are applied."),
357
357
  part: z.number().int().optional().describe("Optional part number filter."),
358
- chapter: z.number().int().optional().describe("Optional chapter number filter."),
358
+ chapter: z.number().int().optional().describe("Optional compatibility chapter number resolved through canonical chapter identity."),
359
359
  chapter_id: z.string().optional().describe("Optional canonical chapter identifier."),
360
360
  only_stale: z.boolean().optional().describe("If true, only process scenes currently marked metadata_stale."),
361
361
  dry_run: z.boolean().optional().describe("If true (default), returns preview results without writing sidecars."),