@hanna84/mcp-writing 3.21.3 → 3.22.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.
@@ -80,6 +80,30 @@ function backupMutationFields(backupResult) {
80
80
  };
81
81
  }
82
82
 
83
+ function normalizeStringList(value) {
84
+ if (Array.isArray(value)) {
85
+ return value.map((entry) => String(entry).trim()).filter(Boolean);
86
+ }
87
+ if (typeof value === "string") {
88
+ return value.split(",").map((entry) => entry.trim()).filter(Boolean);
89
+ }
90
+ return [];
91
+ }
92
+
93
+ function uniqueSorted(values) {
94
+ return [...new Set(values.map((value) => String(value).trim()).filter(Boolean))].sort();
95
+ }
96
+
97
+ function buildCompatibilityOutput({ refreshed, diagnostics = [], role = "generated_transparency" } = {}) {
98
+ return {
99
+ role,
100
+ generated_transparency: true,
101
+ mutation_surface: false,
102
+ refreshed: Boolean(refreshed),
103
+ diagnostics,
104
+ };
105
+ }
106
+
83
107
  function getProvidedStructuralSceneMetadataFields(fields) {
84
108
  return STRUCTURAL_SCENE_METADATA_FIELDS.filter((field) => Object.hasOwn(fields, field));
85
109
  }
@@ -362,6 +386,57 @@ function resolveReferenceLinkSource({
362
386
  });
363
387
  }
364
388
 
389
+ function getProjectUniverseId(db, projectId) {
390
+ return db.prepare(`SELECT universe_id FROM projects WHERE project_id = ?`).get(projectId)?.universe_id ?? null;
391
+ }
392
+
393
+ function resolveCharacterForProject(db, { characterId, projectId }) {
394
+ const universeId = getProjectUniverseId(db, projectId);
395
+ return db.prepare(`
396
+ SELECT character_id, project_id, universe_id, name
397
+ FROM characters
398
+ WHERE character_id = ?
399
+ AND (
400
+ project_id = ?
401
+ OR (universe_id IS NOT NULL AND universe_id = ?)
402
+ OR (project_id IS NULL AND universe_id IS NULL)
403
+ )
404
+ LIMIT 1
405
+ `).get(characterId, projectId, universeId);
406
+ }
407
+
408
+ function resolvePlaceForProject(db, { placeId, projectId }) {
409
+ const universeId = getProjectUniverseId(db, projectId);
410
+ return db.prepare(`
411
+ SELECT place_id, project_id, universe_id, name
412
+ FROM places
413
+ WHERE place_id = ?
414
+ AND (
415
+ project_id = ?
416
+ OR (universe_id IS NOT NULL AND universe_id = ?)
417
+ OR (project_id IS NULL AND universe_id IS NULL)
418
+ )
419
+ LIMIT 1
420
+ `).get(placeId, projectId, universeId);
421
+ }
422
+
423
+ function querySceneRelationshipSnapshot(db, { sceneId, projectId }) {
424
+ return {
425
+ characters: db.prepare(`
426
+ SELECT character_id
427
+ FROM scene_characters
428
+ WHERE scene_id = ? AND project_id = ?
429
+ ORDER BY character_id
430
+ `).all(sceneId, projectId).map(row => row.character_id),
431
+ places: db.prepare(`
432
+ SELECT place_id
433
+ FROM scene_places
434
+ WHERE scene_id = ? AND project_id = ?
435
+ ORDER BY place_id
436
+ `).all(sceneId, projectId).map(row => row.place_id),
437
+ };
438
+ }
439
+
365
440
  export function registerMetadataTools(s, {
366
441
  db,
367
442
  SYNC_DIR,
@@ -371,6 +446,651 @@ export function registerMetadataTools(s, {
371
446
  jsonResponse,
372
447
  createCanonicalWorldEntity,
373
448
  }) {
449
+ async function trackThreadArcLink({ project_id, thread_id, thread_name, scene_id, beat, status }, { operation }) {
450
+ if (!SYNC_DIR_WRITABLE) {
451
+ return errorResponse("READ_ONLY", "Cannot write thread links: sync dir is read-only.");
452
+ }
453
+ const projectIdCheck = validateProjectId(project_id);
454
+ if (!projectIdCheck.ok) {
455
+ return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
456
+ }
457
+
458
+ const existingThread = db.prepare(`SELECT thread_id, project_id FROM threads WHERE thread_id = ?`).get(thread_id);
459
+ if (existingThread && existingThread.project_id !== project_id) {
460
+ return errorResponse(
461
+ "CONFLICT",
462
+ `Thread '${thread_id}' already exists in project '${existingThread.project_id}', cannot reuse it for project '${project_id}'.`
463
+ );
464
+ }
465
+
466
+ const scene = db.prepare(`SELECT scene_id FROM scenes WHERE scene_id = ? AND project_id = ?`).get(scene_id, project_id);
467
+ if (!scene) {
468
+ return errorResponse("NOT_FOUND", `Scene '${scene_id}' not found in project '${project_id}'.`);
469
+ }
470
+
471
+ try {
472
+ db.exec("BEGIN");
473
+ db.prepare(`
474
+ INSERT INTO threads (thread_id, project_id, name, status)
475
+ VALUES (?, ?, ?, ?)
476
+ ON CONFLICT (thread_id) DO UPDATE SET
477
+ name = excluded.name,
478
+ status = excluded.status
479
+ `).run(thread_id, project_id, thread_name, status ?? "active");
480
+
481
+ db.prepare(`
482
+ INSERT INTO scene_threads (scene_id, project_id, thread_id, beat)
483
+ VALUES (?, ?, ?, ?)
484
+ ON CONFLICT (scene_id, project_id, thread_id) DO UPDATE SET
485
+ beat = excluded.beat
486
+ `).run(scene_id, project_id, thread_id, beat ?? null);
487
+ db.exec("COMMIT");
488
+ } catch (err) {
489
+ try {
490
+ db.exec("ROLLBACK");
491
+ } catch (rollbackErr) {
492
+ void rollbackErr;
493
+ }
494
+ return errorResponse("IO_ERROR", `Failed to track thread arc '${thread_id}' for scene '${scene_id}': ${err.message}`);
495
+ }
496
+
497
+ const thread = db.prepare(`SELECT * FROM threads WHERE thread_id = ?`).get(thread_id);
498
+ const link = db.prepare(`SELECT scene_id, project_id, thread_id, beat FROM scene_threads WHERE scene_id = ? AND project_id = ? AND thread_id = ?`)
499
+ .get(scene_id, project_id, thread_id);
500
+ const backupResult = refreshProjectScopedBackupAfterMutation(db, {
501
+ syncDir: SYNC_DIR,
502
+ projectId: project_id,
503
+ applicationVersion: MCP_SERVER_VERSION,
504
+ operation,
505
+ actor: createToolActor(operation),
506
+ affected: {
507
+ threads: [thread_id],
508
+ scenes: [scene_id],
509
+ },
510
+ summary: `Tracked thread "${thread_id}" for scene "${scene_id}".`,
511
+ before: null,
512
+ after: {
513
+ thread,
514
+ link,
515
+ },
516
+ });
517
+
518
+ return jsonResponse({
519
+ ok: true,
520
+ action: operation === "upsert_thread_link" ? "upserted" : "tracked",
521
+ thread,
522
+ link,
523
+ mutation_order: ["validated_request", "sqlite_commit", "project_backup_refresh"],
524
+ ...backupMutationFields(backupResult),
525
+ });
526
+ }
527
+
528
+ function writeReferenceLinkCompatibilityOutput({ sourceKind, sourceFilePath, targetDocId, relation }) {
529
+ if (sourceKind === "scene") {
530
+ persistSceneReferenceLink({
531
+ scenePath: sourceFilePath,
532
+ syncDir: SYNC_DIR,
533
+ targetDocId,
534
+ relation,
535
+ });
536
+ } else if (sourceKind === "character") {
537
+ persistCharacterReferenceLink({
538
+ characterPath: sourceFilePath,
539
+ syncDir: SYNC_DIR,
540
+ targetDocId,
541
+ relation,
542
+ });
543
+ } else if (sourceKind === "place") {
544
+ persistPlaceReferenceLink({
545
+ placePath: sourceFilePath,
546
+ syncDir: SYNC_DIR,
547
+ targetDocId,
548
+ relation,
549
+ });
550
+ } else {
551
+ persistReferenceDocLink({
552
+ filePath: sourceFilePath,
553
+ syncDir: SYNC_DIR,
554
+ targetDocId,
555
+ relation,
556
+ });
557
+ }
558
+ }
559
+
560
+ async function linkReferenceEvidence({
561
+ source_kind,
562
+ source_id,
563
+ source_project_id,
564
+ target_doc_id,
565
+ relation,
566
+ }, { operation }) {
567
+ if (!SYNC_DIR_WRITABLE) {
568
+ return errorResponse("READ_ONLY", "Cannot write reference links: sync dir is read-only.");
569
+ }
570
+
571
+ const normalizedRelation = relation.trim().toLowerCase();
572
+ if (!/^[a-z][a-z0-9_-]*$/.test(normalizedRelation)) {
573
+ return errorResponse(
574
+ "VALIDATION_ERROR",
575
+ "Relation is normalized to lowercase and must match [a-z][a-z0-9_-]* after normalization (for example: 'informs' or 'history_of').",
576
+ { relation }
577
+ );
578
+ }
579
+
580
+ const targetDoc = db.prepare(`
581
+ SELECT doc_id, project_id
582
+ FROM reference_docs
583
+ WHERE doc_id = ?
584
+ `).get(target_doc_id);
585
+ if (!targetDoc) {
586
+ return errorResponse("NOT_FOUND", `Target reference doc '${target_doc_id}' not found.`);
587
+ }
588
+
589
+ const sourceResolution = resolveReferenceLinkSource({
590
+ db,
591
+ errorResponse,
592
+ sourceKind: source_kind,
593
+ sourceId: source_id,
594
+ sourceProjectId: source_project_id,
595
+ targetDocId: target_doc_id,
596
+ });
597
+ if (sourceResolution.error) {
598
+ return sourceResolution.error;
599
+ }
600
+ const { resolvedSourceProjectId, sourceFilePath } = sourceResolution.value;
601
+
602
+ try {
603
+ db.exec("BEGIN");
604
+ upsertExplicitReferenceLinkRow(db, {
605
+ sourceKind: source_kind,
606
+ sourceProjectId: resolvedSourceProjectId,
607
+ sourceId: source_id,
608
+ targetDocId: target_doc_id,
609
+ relation: normalizedRelation,
610
+ });
611
+ db.exec("COMMIT");
612
+ } catch (err) {
613
+ try {
614
+ db.exec("ROLLBACK");
615
+ } catch (rollbackErr) {
616
+ void rollbackErr;
617
+ }
618
+ return errorResponse("IO_ERROR", `Failed to link reference evidence in SQLite: ${err.message}`);
619
+ }
620
+
621
+ const link = db.prepare(`
622
+ SELECT source_kind, source_project_id, source_id, target_doc_id, relation, origin
623
+ FROM reference_links
624
+ WHERE source_kind = ? AND source_project_id = ? AND source_id = ? AND target_doc_id = ? AND relation = ?
625
+ `).get(source_kind, resolvedSourceProjectId, source_id, target_doc_id, normalizedRelation);
626
+ const backupProjectId = resolvedSourceProjectId || targetDoc.project_id || null;
627
+ const backupResult = refreshProjectScopedBackupAfterMutation(db, {
628
+ syncDir: SYNC_DIR,
629
+ projectId: backupProjectId,
630
+ applicationVersion: MCP_SERVER_VERSION,
631
+ operation,
632
+ actor: createToolActor(operation),
633
+ affected: {
634
+ reference_docs: [target_doc_id],
635
+ sources: [`${source_kind}:${source_id}`],
636
+ },
637
+ summary: `Linked ${source_kind} evidence from "${source_id}" to "${target_doc_id}".`,
638
+ before: null,
639
+ after: {
640
+ link,
641
+ },
642
+ });
643
+
644
+ const compatibilityDiagnostics = [];
645
+ if (!sourceFilePath) {
646
+ compatibilityDiagnostics.push({
647
+ code: "STALE_PATH",
648
+ severity: "warning",
649
+ message: `Canonical reference link was committed, but ${source_kind} '${source_id}' has no indexed file path for generated compatibility output.`,
650
+ next_step: "Treat SQLite and project backup artifacts as current. Run sync, inspect the indexed source path, then retry link_reference_evidence if compatibility output is still needed.",
651
+ details: {
652
+ source_kind,
653
+ source_id,
654
+ source_project_id: resolvedSourceProjectId,
655
+ target_doc_id,
656
+ indexed_path: null,
657
+ },
658
+ });
659
+ } else {
660
+ try {
661
+ writeReferenceLinkCompatibilityOutput({
662
+ sourceKind: source_kind,
663
+ sourceFilePath,
664
+ targetDocId: target_doc_id,
665
+ relation: normalizedRelation,
666
+ });
667
+ } catch (err) {
668
+ compatibilityDiagnostics.push({
669
+ code: err?.code ?? "COMPATIBILITY_OUTPUT_FAILED",
670
+ severity: "warning",
671
+ message: `Canonical reference link was committed, but generated compatibility metadata for ${source_kind} '${source_id}' could not be refreshed: ${err.message}`,
672
+ next_step: "Treat SQLite and project backup artifacts as current. Run sync, inspect the indexed source path, then retry link_reference_evidence if compatibility output is still needed.",
673
+ details: {
674
+ source_kind,
675
+ source_id,
676
+ source_project_id: resolvedSourceProjectId,
677
+ target_doc_id,
678
+ indexed_path: sourceFilePath,
679
+ },
680
+ });
681
+ }
682
+ }
683
+
684
+ return jsonResponse({
685
+ ok: true,
686
+ action: operation === "upsert_reference_link" ? "upserted" : "linked",
687
+ link,
688
+ mutation_order: [
689
+ "validated_request",
690
+ "sqlite_commit",
691
+ "project_backup_refresh",
692
+ "compatibility_output_refresh",
693
+ ],
694
+ compatibility_output: {
695
+ generated_transparency: true,
696
+ mutation_surface: false,
697
+ refreshed: compatibilityDiagnostics.length === 0,
698
+ },
699
+ compatibility_diagnostics: compatibilityDiagnostics,
700
+ ...backupMutationFields(backupResult),
701
+ });
702
+ }
703
+
704
+ async function connectCharacterPlaceEvidence({
705
+ project_id,
706
+ scene_id,
707
+ character_id,
708
+ place_id,
709
+ note,
710
+ }) {
711
+ if (!SYNC_DIR_WRITABLE) {
712
+ return errorResponse("READ_ONLY", "Cannot connect character/place evidence: sync dir is read-only.");
713
+ }
714
+ const projectIdCheck = validateProjectId(project_id);
715
+ if (!projectIdCheck.ok) {
716
+ return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
717
+ }
718
+
719
+ const scene = db.prepare(`
720
+ SELECT scene_id, project_id, file_path
721
+ FROM scenes
722
+ WHERE scene_id = ? AND project_id = ?
723
+ `).get(scene_id, project_id);
724
+ if (!scene) {
725
+ return errorResponse("NOT_FOUND", `Scene '${scene_id}' not found in project '${project_id}'.`);
726
+ }
727
+
728
+ const character = resolveCharacterForProject(db, { characterId: character_id, projectId: project_id });
729
+ if (!character) {
730
+ return errorResponse("NOT_FOUND", `Character '${character_id}' is not indexed for project '${project_id}' or its universe.`);
731
+ }
732
+ const place = resolvePlaceForProject(db, { placeId: place_id, projectId: project_id });
733
+ if (!place) {
734
+ return errorResponse("NOT_FOUND", `Place '${place_id}' is not indexed for project '${project_id}' or its universe.`);
735
+ }
736
+
737
+ const before = querySceneRelationshipSnapshot(db, { sceneId: scene_id, projectId: project_id });
738
+ try {
739
+ db.exec("BEGIN");
740
+ db.prepare(`
741
+ INSERT OR IGNORE INTO scene_characters (scene_id, project_id, character_id)
742
+ VALUES (?, ?, ?)
743
+ `).run(scene_id, project_id, character_id);
744
+ db.prepare(`
745
+ INSERT OR IGNORE INTO scene_places (scene_id, project_id, place_id)
746
+ VALUES (?, ?, ?)
747
+ `).run(scene_id, project_id, place_id);
748
+ db.exec("COMMIT");
749
+ } catch (err) {
750
+ try {
751
+ db.exec("ROLLBACK");
752
+ } catch (rollbackErr) {
753
+ void rollbackErr;
754
+ }
755
+ return errorResponse("IO_ERROR", `Failed to connect character/place evidence for scene '${scene_id}': ${err.message}`);
756
+ }
757
+
758
+ const after = querySceneRelationshipSnapshot(db, { sceneId: scene_id, projectId: project_id });
759
+ const backupResult = refreshProjectScopedBackupAfterMutation(db, {
760
+ syncDir: SYNC_DIR,
761
+ projectId: project_id,
762
+ applicationVersion: MCP_SERVER_VERSION,
763
+ operation: "connect_character_place_evidence",
764
+ actor: createToolActor("connect_character_place_evidence"),
765
+ affected: {
766
+ scenes: [scene_id],
767
+ characters: [character_id],
768
+ places: [place_id],
769
+ },
770
+ summary: `Connected character "${character_id}" and place "${place_id}" as evidence in scene "${scene_id}".`,
771
+ before: {
772
+ scene_relationships: before,
773
+ },
774
+ after: {
775
+ scene_relationships: after,
776
+ },
777
+ metadata: {
778
+ note: note ?? null,
779
+ },
780
+ });
781
+
782
+ const compatibilityDiagnostics = [];
783
+ if (!scene.file_path) {
784
+ compatibilityDiagnostics.push({
785
+ code: "STALE_PATH",
786
+ severity: "warning",
787
+ message: `Canonical scene relationship evidence was committed, but scene '${scene_id}' has no indexed file path for generated compatibility output.`,
788
+ next_step: "Treat SQLite and project backup artifacts as current. Run sync and inspect the indexed scene path before retrying compatibility output.",
789
+ details: {
790
+ scene_id,
791
+ project_id,
792
+ indexed_path: null,
793
+ },
794
+ });
795
+ } else {
796
+ try {
797
+ const { sourceMeta } = readSourceMeta(scene.file_path, SYNC_DIR, { writable: true });
798
+ writeMeta(scene.file_path, {
799
+ ...sourceMeta,
800
+ characters: uniqueSorted([...normalizeStringList(sourceMeta.characters), character_id]),
801
+ places: uniqueSorted([...normalizeStringList(sourceMeta.places), place_id]),
802
+ }, { syncDir: SYNC_DIR });
803
+ } catch (err) {
804
+ compatibilityDiagnostics.push({
805
+ code: err?.code ?? "COMPATIBILITY_OUTPUT_FAILED",
806
+ severity: "warning",
807
+ message: `Canonical scene relationship evidence was committed, but generated scene metadata compatibility output could not be refreshed: ${err.message}`,
808
+ next_step: "Treat SQLite and project backup artifacts as current. Run sync and inspect the indexed scene path before retrying compatibility output.",
809
+ details: {
810
+ scene_id,
811
+ project_id,
812
+ indexed_path: scene.file_path,
813
+ },
814
+ });
815
+ }
816
+ }
817
+
818
+ return jsonResponse({
819
+ ok: true,
820
+ action: "connected",
821
+ scene_id,
822
+ project_id,
823
+ character_id,
824
+ place_id,
825
+ note: note ?? null,
826
+ mutation_order: [
827
+ "validated_request",
828
+ "sqlite_commit",
829
+ "project_backup_refresh",
830
+ "compatibility_output_refresh",
831
+ ],
832
+ compatibility_output: buildCompatibilityOutput({
833
+ refreshed: compatibilityDiagnostics.length === 0,
834
+ diagnostics: compatibilityDiagnostics,
835
+ }),
836
+ ...backupMutationFields(backupResult),
837
+ });
838
+ }
839
+
840
+ async function recordCharacterRelationshipBeat({
841
+ project_id,
842
+ from_character,
843
+ to_character,
844
+ relationship_type,
845
+ strength,
846
+ scene_id,
847
+ note,
848
+ }) {
849
+ if (!SYNC_DIR_WRITABLE) {
850
+ return errorResponse("READ_ONLY", "Cannot record character relationship beats: sync dir is read-only.");
851
+ }
852
+ const projectIdCheck = validateProjectId(project_id);
853
+ if (!projectIdCheck.ok) {
854
+ return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
855
+ }
856
+ if (from_character === to_character) {
857
+ return errorResponse("VALIDATION_ERROR", "from_character and to_character must be different characters.");
858
+ }
859
+
860
+ const fromCharacter = resolveCharacterForProject(db, { characterId: from_character, projectId: project_id });
861
+ if (!fromCharacter) {
862
+ return errorResponse("NOT_FOUND", `Character '${from_character}' is not indexed for project '${project_id}' or its universe.`);
863
+ }
864
+ const toCharacter = resolveCharacterForProject(db, { characterId: to_character, projectId: project_id });
865
+ if (!toCharacter) {
866
+ return errorResponse("NOT_FOUND", `Character '${to_character}' is not indexed for project '${project_id}' or its universe.`);
867
+ }
868
+ const scene = db.prepare(`
869
+ SELECT scene_id, project_id
870
+ FROM scenes
871
+ WHERE scene_id = ? AND project_id = ?
872
+ `).get(scene_id, project_id);
873
+ if (!scene) {
874
+ return errorResponse("NOT_FOUND", `Scene '${scene_id}' not found in project '${project_id}'.`);
875
+ }
876
+
877
+ const normalizedRelationshipType = relationship_type.trim().toLowerCase();
878
+ if (!/^[a-z][a-z0-9_-]*$/.test(normalizedRelationshipType)) {
879
+ return errorResponse(
880
+ "VALIDATION_ERROR",
881
+ "relationship_type is normalized to lowercase and must match [a-z][a-z0-9_-]* after normalization.",
882
+ { relationship_type }
883
+ );
884
+ }
885
+ const normalizedNote = note?.trim() || null;
886
+ const before = db.prepare(`
887
+ SELECT from_character, to_character, relationship_type, strength, scene_id, note
888
+ FROM character_relationships
889
+ WHERE from_character = ? AND to_character = ? AND relationship_type = ? AND scene_id = ? AND COALESCE(note, '') = COALESCE(?, '')
890
+ `).all(from_character, to_character, normalizedRelationshipType, scene_id, normalizedNote);
891
+
892
+ try {
893
+ db.exec("BEGIN");
894
+ db.prepare(`
895
+ DELETE FROM character_relationships
896
+ WHERE from_character = ? AND to_character = ? AND relationship_type = ? AND scene_id = ? AND COALESCE(note, '') = COALESCE(?, '')
897
+ `).run(from_character, to_character, normalizedRelationshipType, scene_id, normalizedNote);
898
+ db.prepare(`
899
+ INSERT INTO character_relationships (from_character, to_character, relationship_type, strength, scene_id, note)
900
+ VALUES (?, ?, ?, ?, ?, ?)
901
+ `).run(from_character, to_character, normalizedRelationshipType, strength ?? null, scene_id, normalizedNote);
902
+ db.exec("COMMIT");
903
+ } catch (err) {
904
+ try {
905
+ db.exec("ROLLBACK");
906
+ } catch (rollbackErr) {
907
+ void rollbackErr;
908
+ }
909
+ return errorResponse("IO_ERROR", `Failed to record relationship beat for '${from_character}' and '${to_character}': ${err.message}`);
910
+ }
911
+
912
+ const relationship = db.prepare(`
913
+ SELECT from_character, to_character, relationship_type, strength, scene_id, note
914
+ FROM character_relationships
915
+ WHERE from_character = ? AND to_character = ? AND relationship_type = ? AND scene_id = ? AND COALESCE(note, '') = COALESCE(?, '')
916
+ `).get(from_character, to_character, normalizedRelationshipType, scene_id, normalizedNote);
917
+ const backupResult = refreshProjectScopedBackupAfterMutation(db, {
918
+ syncDir: SYNC_DIR,
919
+ projectId: project_id,
920
+ applicationVersion: MCP_SERVER_VERSION,
921
+ operation: "record_character_relationship_beat",
922
+ actor: createToolActor("record_character_relationship_beat"),
923
+ affected: {
924
+ scenes: [scene_id],
925
+ characters: [from_character, to_character],
926
+ },
927
+ summary: `Recorded "${normalizedRelationshipType}" beat for "${from_character}" and "${to_character}" in scene "${scene_id}".`,
928
+ before: {
929
+ relationships: before,
930
+ },
931
+ after: {
932
+ relationship,
933
+ },
934
+ });
935
+
936
+ return jsonResponse({
937
+ ok: true,
938
+ action: "recorded",
939
+ relationship,
940
+ mutation_order: ["validated_request", "sqlite_commit", "project_backup_refresh"],
941
+ compatibility_output: {
942
+ role: "none",
943
+ reason: "Character relationship beats are canonical SQLite state and have no sidecar compatibility output in M4.",
944
+ },
945
+ ...backupMutationFields(backupResult),
946
+ });
947
+ }
948
+
949
+ async function auditRelationshipMetadata({ project_id }) {
950
+ const projectFilter = project_id ? `WHERE project_id = ?` : "";
951
+ const projectParams = project_id ? [project_id] : [];
952
+ const projectScope = project_id
953
+ ? db.prepare(`SELECT universe_id FROM projects WHERE project_id = ?`).get(project_id)
954
+ : null;
955
+ const entityFilter = project_id
956
+ ? projectScope
957
+ ? `WHERE project_id = ?
958
+ OR (universe_id IS NOT NULL AND universe_id = ?)
959
+ OR (project_id IS NULL AND universe_id IS NULL)`
960
+ : "WHERE 0"
961
+ : "";
962
+ const entityParams = project_id && projectScope
963
+ ? [project_id, projectScope.universe_id]
964
+ : [];
965
+ const scenes = db.prepare(`
966
+ SELECT scene_id, project_id, file_path, metadata_stale
967
+ FROM scenes
968
+ ${projectFilter}
969
+ ORDER BY project_id, scene_id
970
+ `).all(...projectParams);
971
+ const characters = db.prepare(`
972
+ SELECT character_id, project_id, universe_id, file_path
973
+ FROM characters
974
+ ${entityFilter}
975
+ ORDER BY character_id
976
+ `).all(...entityParams);
977
+ const places = db.prepare(`
978
+ SELECT place_id, project_id, universe_id, file_path
979
+ FROM places
980
+ ${entityFilter}
981
+ ORDER BY place_id
982
+ `).all(...entityParams);
983
+
984
+ const diagnostics = [];
985
+ for (const scene of scenes) {
986
+ if (scene.metadata_stale) {
987
+ diagnostics.push({
988
+ type: "stale_scene_relationship_index",
989
+ severity: "warning",
990
+ message: `Scene '${scene.scene_id}' has stale metadata; relationship indexes may lag prose.`,
991
+ scene_id: scene.scene_id,
992
+ project_id: scene.project_id,
993
+ next_step: "Use enrich_scene or enrich_scene_characters_batch dry_run to review prose-derived repairs before applying.",
994
+ });
995
+ }
996
+ try {
997
+ const { sourceMeta } = readSourceMeta(scene.file_path, SYNC_DIR, { writable: false });
998
+ if (sourceMeta.threads) {
999
+ diagnostics.push({
1000
+ type: "sidecar_threads_compatibility_input",
1001
+ severity: "info",
1002
+ message: `Scene '${scene.scene_id}' still has sidecar thread metadata; use track_thread_arc for current thread authority.`,
1003
+ scene_id: scene.scene_id,
1004
+ project_id: scene.project_id,
1005
+ });
1006
+ }
1007
+ } catch (err) {
1008
+ diagnostics.push({
1009
+ type: "scene_relationship_metadata_unreadable",
1010
+ severity: "warning",
1011
+ message: `Could not read scene metadata for '${scene.scene_id}': ${err.message}`,
1012
+ scene_id: scene.scene_id,
1013
+ project_id: scene.project_id,
1014
+ });
1015
+ }
1016
+ }
1017
+
1018
+ for (const character of characters) {
1019
+ if (!character.file_path) continue;
1020
+ try {
1021
+ const { sourceMeta } = readSourceMeta(character.file_path, SYNC_DIR, { writable: false });
1022
+ if (normalizeStringList(sourceMeta.tags).length > 0) {
1023
+ diagnostics.push({
1024
+ type: "character_tags_review_note",
1025
+ severity: "info",
1026
+ message: `Character '${character.character_id}' has sidecar tags; M4 treats these as compatibility/review notes, not relationship authority.`,
1027
+ character_id: character.character_id,
1028
+ });
1029
+ }
1030
+ } catch {
1031
+ // Character prose/metadata files are compatibility output for this audit.
1032
+ }
1033
+ }
1034
+
1035
+ for (const place of places) {
1036
+ if (!place.file_path) continue;
1037
+ try {
1038
+ const { sourceMeta } = readSourceMeta(place.file_path, SYNC_DIR, { writable: false });
1039
+ if (normalizeStringList(sourceMeta.associated_characters).length > 0) {
1040
+ diagnostics.push({
1041
+ type: "place_associated_characters_review_note",
1042
+ severity: "info",
1043
+ message: `Place '${place.place_id}' has associated_characters sidecar metadata; use connect_character_place_evidence for current scene-backed authority.`,
1044
+ place_id: place.place_id,
1045
+ });
1046
+ }
1047
+ if (normalizeStringList(sourceMeta.tags).length > 0) {
1048
+ diagnostics.push({
1049
+ type: "place_tags_review_note",
1050
+ severity: "info",
1051
+ message: `Place '${place.place_id}' has sidecar tags; M4 treats these as compatibility/review notes, not relationship authority.`,
1052
+ place_id: place.place_id,
1053
+ });
1054
+ }
1055
+ } catch {
1056
+ // Place prose/metadata files are compatibility output for this audit.
1057
+ }
1058
+ }
1059
+
1060
+ return jsonResponse({
1061
+ ok: true,
1062
+ project_id: project_id ?? null,
1063
+ audit_authority: {
1064
+ canonical_relationship_sources: [
1065
+ "scene_characters",
1066
+ "scene_places",
1067
+ "threads",
1068
+ "scene_threads",
1069
+ "character_relationships",
1070
+ "reference_links",
1071
+ ],
1072
+ compatibility_sources: [
1073
+ "scene sidecar characters/places/tags/threads",
1074
+ "character sidecar tags",
1075
+ "place sidecar associated_characters/tags",
1076
+ ],
1077
+ compatibility_mutation_surface: false,
1078
+ },
1079
+ diagnostics,
1080
+ summary: {
1081
+ diagnostics_count: diagnostics.length,
1082
+ stale_scene_count: diagnostics.filter(diagnostic => diagnostic.type === "stale_scene_relationship_index").length,
1083
+ compatibility_note_count: diagnostics.filter(diagnostic => diagnostic.severity === "info").length,
1084
+ },
1085
+ next_steps: [
1086
+ "Use connect_character_place_evidence for scene-backed character/place relationships.",
1087
+ "Use record_character_relationship_beat for relationship arcs between characters.",
1088
+ "Use link_reference_evidence for explicit reference evidence.",
1089
+ "Use export_project_backup when a fresh recovery snapshot is needed.",
1090
+ ],
1091
+ });
1092
+ }
1093
+
374
1094
  // ---- create_character_sheet ---------------------------------------------
375
1095
  s.tool(
376
1096
  "create_character_sheet",
@@ -529,249 +1249,102 @@ export function registerMetadataTools(s, {
529
1249
  }
530
1250
  );
531
1251
 
1252
+ // ---- track_thread_arc ----------------------------------------------------
1253
+ s.tool(
1254
+ "track_thread_arc",
1255
+ "Track a storyline, subplot, or recurring arc through a scene by recording the scene's thread beat. This is the outcome-level workflow for thread relationship changes: callers provide story intent, while SQLite thread tables, backup refresh, and rollback stay implementation details.",
1256
+ {
1257
+ project_id: z.string().describe("Project the thread belongs to (e.g. 'the-lamb')."),
1258
+ thread_id: z.string().describe("Stable thread ID for the arc being tracked (e.g. 'thread-reconciliation')."),
1259
+ thread_name: z.string().describe("Human-readable thread or arc name."),
1260
+ scene_id: z.string().describe("Scene that carries this thread beat (e.g. 'sc-011-sebastian')."),
1261
+ beat: z.string().optional().describe("Optional story beat for this scene in the thread, such as setup, escalation, reveal, reversal, or payoff."),
1262
+ status: z.string().optional().describe("Thread status (e.g. 'active', 'resolved'). Defaults to 'active'."),
1263
+ },
1264
+ async (args) => trackThreadArcLink(args, { operation: "track_thread_arc" })
1265
+ );
1266
+
532
1267
  // ---- upsert_thread_link --------------------------------------------------
533
1268
  s.tool(
534
1269
  "upsert_thread_link",
535
- "Create or update a thread and link it to a scene. Idempotent: if the link already exists, updates its beat. Only available when the sync dir is writable.",
1270
+ "Compatibility name for track_thread_arc. Prefer track_thread_arc when recording story intent; this retained alias still validates, writes SQLite first, refreshes project backups, and rolls back failed canonical writes.",
536
1271
  {
537
1272
  project_id: z.string().describe("Project the thread belongs to (e.g. 'the-lamb')."),
538
- thread_id: z.string().describe("Thread ID (e.g. 'thread-reconciliation')."),
539
- thread_name: z.string().describe("Thread display name."),
540
- scene_id: z.string().describe("Scene to link to the thread (e.g. 'sc-011-sebastian')."),
541
- beat: z.string().optional().describe("Optional thread-specific beat label for this scene."),
1273
+ thread_id: z.string().describe("Stable thread ID for the arc being tracked (e.g. 'thread-reconciliation')."),
1274
+ thread_name: z.string().describe("Human-readable thread or arc name."),
1275
+ scene_id: z.string().describe("Scene that carries this thread beat (e.g. 'sc-011-sebastian')."),
1276
+ beat: z.string().optional().describe("Optional story beat for this scene in the thread, such as setup, escalation, reveal, reversal, or payoff."),
542
1277
  status: z.string().optional().describe("Thread status (e.g. 'active', 'resolved'). Defaults to 'active'."),
543
1278
  },
544
- async ({ project_id, thread_id, thread_name, scene_id, beat, status }) => {
545
- if (!SYNC_DIR_WRITABLE) {
546
- return errorResponse("READ_ONLY", "Cannot write thread links: sync dir is read-only.");
547
- }
548
- const projectIdCheck = validateProjectId(project_id);
549
- if (!projectIdCheck.ok) {
550
- return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
551
- }
552
-
553
- const existingThread = db.prepare(`SELECT thread_id, project_id FROM threads WHERE thread_id = ?`).get(thread_id);
554
- if (existingThread && existingThread.project_id !== project_id) {
555
- return errorResponse(
556
- "CONFLICT",
557
- `Thread '${thread_id}' already exists in project '${existingThread.project_id}', cannot reuse it for project '${project_id}'.`
558
- );
559
- }
560
-
561
- const scene = db.prepare(`SELECT scene_id FROM scenes WHERE scene_id = ? AND project_id = ?`).get(scene_id, project_id);
562
- if (!scene) {
563
- return errorResponse("NOT_FOUND", `Scene '${scene_id}' not found in project '${project_id}'.`);
564
- }
565
-
566
- db.prepare(`
567
- INSERT INTO threads (thread_id, project_id, name, status)
568
- VALUES (?, ?, ?, ?)
569
- ON CONFLICT (thread_id) DO UPDATE SET
570
- name = excluded.name,
571
- status = excluded.status
572
- `).run(thread_id, project_id, thread_name, status ?? "active");
573
-
574
- db.prepare(`
575
- INSERT INTO scene_threads (scene_id, project_id, thread_id, beat)
576
- VALUES (?, ?, ?, ?)
577
- ON CONFLICT (scene_id, project_id, thread_id) DO UPDATE SET
578
- beat = excluded.beat
579
- `).run(scene_id, project_id, thread_id, beat ?? null);
580
-
581
- const thread = db.prepare(`SELECT * FROM threads WHERE thread_id = ?`).get(thread_id);
582
- const link = db.prepare(`SELECT scene_id, project_id, thread_id, beat FROM scene_threads WHERE scene_id = ? AND project_id = ? AND thread_id = ?`)
583
- .get(scene_id, project_id, thread_id);
584
- const backupResult = refreshProjectScopedBackupAfterMutation(db, {
585
- syncDir: SYNC_DIR,
586
- projectId: project_id,
587
- applicationVersion: MCP_SERVER_VERSION,
588
- operation: "upsert_thread_link",
589
- actor: createToolActor("upsert_thread_link"),
590
- affected: {
591
- threads: [thread_id],
592
- scenes: [scene_id],
593
- },
594
- summary: `Upserted thread "${thread_id}" link for scene "${scene_id}".`,
595
- before: null,
596
- after: {
597
- thread,
598
- link,
599
- },
600
- });
1279
+ async (args) => trackThreadArcLink(args, { operation: "upsert_thread_link" })
1280
+ );
601
1281
 
602
- return jsonResponse({
603
- ok: true,
604
- action: "upserted",
605
- thread,
606
- link,
607
- ...backupMutationFields(backupResult),
608
- });
609
- }
1282
+ // ---- link_reference_evidence --------------------------------------------
1283
+ s.tool(
1284
+ "link_reference_evidence",
1285
+ "Link scene, character, place, or reference evidence to a reference document. This is the outcome-level workflow for evidence relationships: SQLite commits first, project backup artifacts refresh after commit, and sidecar/frontmatter compatibility output is refreshed only as generated transparency.",
1286
+ {
1287
+ source_kind: z.enum(["scene", "character", "place", "reference"]).describe("Evidence source kind."),
1288
+ source_id: z.string().describe("Source scene_id, character_id, place_id, or reference doc_id."),
1289
+ source_project_id: z.string().optional().describe("Optional project scope for the source. For scene/character/place sources, use this to disambiguate an ambiguous source_id across projects. For reference sources, when provided, it is treated as an ownership check and must match the source reference doc's project."),
1290
+ target_doc_id: z.string().describe("Target reference doc_id."),
1291
+ relation: z.string().describe("Evidence relationship label (for example: 'informs', 'related', 'history_of'). The value is trimmed and lowercased before validation."),
1292
+ },
1293
+ async (args) => linkReferenceEvidence(args, { operation: "link_reference_evidence" })
610
1294
  );
611
1295
 
612
1296
  // ---- upsert_reference_link -----------------------------------------------
613
1297
  s.tool(
614
1298
  "upsert_reference_link",
615
- "Create or update an explicit reference link from a scene, character, place, or reference doc to a target reference doc. If a link already exists between the same source and target, this updates the relation. Only available when the sync dir is writable.",
1299
+ "Compatibility name for link_reference_evidence. Prefer link_reference_evidence when recording story evidence; this retained alias still validates, commits SQLite first, refreshes project backups, and treats sidecar/frontmatter output as generated compatibility.",
616
1300
  {
617
- source_kind: z.enum(["scene", "character", "place", "reference"]).describe("Link source kind."),
1301
+ source_kind: z.enum(["scene", "character", "place", "reference"]).describe("Evidence source kind."),
618
1302
  source_id: z.string().describe("Source scene_id, character_id, place_id, or reference doc_id."),
619
1303
  source_project_id: z.string().optional().describe("Optional project scope for the source. For scene/character/place sources, use this to disambiguate an ambiguous source_id across projects. For reference sources, when provided, it is treated as an ownership check and must match the source reference doc's project."),
620
1304
  target_doc_id: z.string().describe("Target reference doc_id."),
621
- relation: z.string().describe("Relationship label (for example: 'informs', 'related', 'history_of'). The value is trimmed and lowercased before validation."),
1305
+ relation: z.string().describe("Evidence relationship label (for example: 'informs', 'related', 'history_of'). The value is trimmed and lowercased before validation."),
622
1306
  },
623
- async ({ source_kind, source_id, source_project_id, target_doc_id, relation }) => {
624
- if (!SYNC_DIR_WRITABLE) {
625
- return errorResponse("READ_ONLY", "Cannot write reference links: sync dir is read-only.");
626
- }
627
-
628
- const normalizedRelation = relation.trim().toLowerCase();
629
- if (!/^[a-z][a-z0-9_-]*$/.test(normalizedRelation)) {
630
- return errorResponse(
631
- "VALIDATION_ERROR",
632
- "Relation is normalized to lowercase and must match [a-z][a-z0-9_-]* after normalization (for example: 'informs' or 'history_of').",
633
- { relation }
634
- );
635
- }
636
-
637
- const targetDoc = db.prepare(`
638
- SELECT doc_id, project_id
639
- FROM reference_docs
640
- WHERE doc_id = ?
641
- `).get(target_doc_id);
642
- if (!targetDoc) {
643
- return errorResponse("NOT_FOUND", `Target reference doc '${target_doc_id}' not found.`);
644
- }
645
-
646
- const sourceResolution = resolveReferenceLinkSource({
647
- db,
648
- errorResponse,
649
- sourceKind: source_kind,
650
- sourceId: source_id,
651
- sourceProjectId: source_project_id,
652
- targetDocId: target_doc_id,
653
- });
654
- if (sourceResolution.error) {
655
- return sourceResolution.error;
656
- }
657
- const { resolvedSourceProjectId, sourceFilePath } = sourceResolution.value;
658
-
659
- try {
660
- if (source_kind === "scene") {
661
- if (!sourceFilePath) {
662
- return errorResponse("STALE_PATH", `Scene '${source_id}' has no indexed file path. Run sync() to refresh.`, {
663
- source_id,
664
- source_project_id: resolvedSourceProjectId,
665
- });
666
- }
667
- persistSceneReferenceLink({
668
- scenePath: sourceFilePath,
669
- syncDir: SYNC_DIR,
670
- targetDocId: target_doc_id,
671
- relation: normalizedRelation,
672
- });
673
- } else if (source_kind === "character") {
674
- if (!sourceFilePath) {
675
- return errorResponse("STALE_PATH", `Character '${source_id}' has no indexed file path. Run sync() to refresh.`, {
676
- source_id,
677
- source_project_id: resolvedSourceProjectId,
678
- });
679
- }
680
- persistCharacterReferenceLink({
681
- characterPath: sourceFilePath,
682
- syncDir: SYNC_DIR,
683
- targetDocId: target_doc_id,
684
- relation: normalizedRelation,
685
- });
686
- } else if (source_kind === "place") {
687
- if (!sourceFilePath) {
688
- return errorResponse("STALE_PATH", `Place '${source_id}' has no indexed file path. Run sync() to refresh.`, {
689
- source_id,
690
- source_project_id: resolvedSourceProjectId,
691
- });
692
- }
693
- persistPlaceReferenceLink({
694
- placePath: sourceFilePath,
695
- syncDir: SYNC_DIR,
696
- targetDocId: target_doc_id,
697
- relation: normalizedRelation,
698
- });
699
- } else {
700
- if (!sourceFilePath) {
701
- return errorResponse("STALE_PATH", `Reference doc '${source_id}' has no indexed file path. Run sync() to refresh.`, {
702
- source_id,
703
- });
704
- }
705
- persistReferenceDocLink({
706
- filePath: sourceFilePath,
707
- syncDir: SYNC_DIR,
708
- targetDocId: target_doc_id,
709
- relation: normalizedRelation,
710
- });
711
- }
712
- } catch (err) {
713
- if (err?.code === "ENOENT") {
714
- return errorResponse(
715
- "STALE_PATH",
716
- `Source file for ${source_kind} '${source_id}' not found at indexed path — run sync() to refresh.`,
717
- { indexed_path: sourceFilePath }
718
- );
719
- }
720
- if (err?.name === "CoreValidationError") {
721
- return errorResponse(err.code, err.message, err.details);
722
- }
723
- return errorResponse("IO_ERROR", `Failed to persist link metadata: ${err.message}`);
724
- }
1307
+ async (args) => linkReferenceEvidence(args, { operation: "upsert_reference_link" })
1308
+ );
725
1309
 
726
- try {
727
- db.exec("BEGIN");
728
- upsertExplicitReferenceLinkRow(db, {
729
- sourceKind: source_kind,
730
- sourceProjectId: resolvedSourceProjectId,
731
- sourceId: source_id,
732
- targetDocId: target_doc_id,
733
- relation: normalizedRelation,
734
- });
735
- db.exec("COMMIT");
736
- } catch (err) {
737
- try {
738
- db.exec("ROLLBACK");
739
- } catch (rollbackErr) {
740
- void rollbackErr;
741
- }
742
- throw err;
743
- }
1310
+ // ---- connect_character_place_evidence ------------------------------------
1311
+ s.tool(
1312
+ "connect_character_place_evidence",
1313
+ "Connect a character and place as scene-backed story evidence. This is the outcome-level workflow for character/place association: SQLite scene relationship indexes commit first, project backups refresh after commit, and scene sidecar characters/places are refreshed only as generated compatibility output.",
1314
+ {
1315
+ project_id: z.string().describe("Project the scene belongs to (e.g. 'the-lamb')."),
1316
+ scene_id: z.string().describe("Scene that provides the evidence for this character/place association."),
1317
+ character_id: z.string().describe("Character present in the scene. Use list_characters to find valid IDs."),
1318
+ place_id: z.string().describe("Place present in the scene. Use list_places to find valid IDs."),
1319
+ note: z.string().optional().describe("Optional review note explaining the evidence. Stored in operation history, not in compatibility sidecars."),
1320
+ },
1321
+ async (args) => connectCharacterPlaceEvidence(args)
1322
+ );
744
1323
 
745
- const link = db.prepare(`
746
- SELECT source_kind, source_project_id, source_id, target_doc_id, relation, origin
747
- FROM reference_links
748
- WHERE source_kind = ? AND source_project_id = ? AND source_id = ? AND target_doc_id = ? AND relation = ?
749
- `).get(source_kind, resolvedSourceProjectId, source_id, target_doc_id, normalizedRelation);
750
- const backupProjectId = resolvedSourceProjectId || targetDoc.project_id || null;
751
- const backupResult = refreshProjectScopedBackupAfterMutation(db, {
752
- syncDir: SYNC_DIR,
753
- projectId: backupProjectId,
754
- applicationVersion: MCP_SERVER_VERSION,
755
- operation: "upsert_reference_link",
756
- actor: createToolActor("upsert_reference_link"),
757
- affected: {
758
- reference_docs: [target_doc_id],
759
- sources: [`${source_kind}:${source_id}`],
760
- },
761
- summary: `Upserted ${source_kind} reference link from "${source_id}" to "${target_doc_id}".`,
762
- before: null,
763
- after: {
764
- link,
765
- },
766
- });
1324
+ // ---- record_character_relationship_beat ----------------------------------
1325
+ s.tool(
1326
+ "record_character_relationship_beat",
1327
+ "Record how two characters relate in a specific scene. This outcome-level workflow writes character relationship beats directly to SQLite, validates scene evidence, refreshes project backups after commit, and does not require callers to know the character_relationships table.",
1328
+ {
1329
+ project_id: z.string().describe("Project the scene evidence belongs to (e.g. 'the-lamb')."),
1330
+ from_character: z.string().describe("First character_id in the relationship beat."),
1331
+ to_character: z.string().describe("Second character_id in the relationship beat."),
1332
+ relationship_type: z.string().describe("Relationship label such as trusts, protects, fears, betrays, or reconciles. Trimmed and lowercased before validation."),
1333
+ strength: z.string().optional().describe("Optional qualitative strength or direction for this scene beat."),
1334
+ scene_id: z.string().describe("Scene that provides the evidence for this relationship beat."),
1335
+ note: z.string().optional().describe("Optional short evidence note for this beat."),
1336
+ },
1337
+ async (args) => recordCharacterRelationshipBeat(args)
1338
+ );
767
1339
 
768
- return jsonResponse({
769
- ok: true,
770
- action: "upserted",
771
- link,
772
- ...backupMutationFields(backupResult),
773
- });
774
- }
1340
+ // ---- audit_relationship_metadata -----------------------------------------
1341
+ s.tool(
1342
+ "audit_relationship_metadata",
1343
+ "Review relationship metadata authority, stale indexes, and retained compatibility notes without mutating SQLite or files. Use this before repair work when character/place associations, sidecar tags, scene threads, or recovery readiness look stale or ambiguous.",
1344
+ {
1345
+ project_id: z.string().optional().describe("Optional project scope for the audit."),
1346
+ },
1347
+ async (args) => auditRelationshipMetadata(args)
775
1348
  );
776
1349
 
777
1350
  // ---- create_chapter ------------------------------------------------------
@@ -1746,7 +2319,7 @@ export function registerMetadataTools(s, {
1746
2319
  // ---- update_character_sheet ----------------------------------------------
1747
2320
  s.tool(
1748
2321
  "update_character_sheet",
1749
- "Update structured metadata fields for a character (role, arc_summary, traits, etc). Writes to the .meta.yaml sidecar never modifies the prose notes file. Changes are immediately reflected in the index. Only available when the sync dir is writable.",
2322
+ "Update canonical character profile fields such as name, role, arc_summary, first_appearance, and traits. SQLite commits first, project backups refresh after commit, and the .meta.yaml file is refreshed only as generated compatibility output; prose notes are never modified.",
1750
2323
  {
1751
2324
  character_id: z.string().describe("The character_id to update (e.g. 'char-mira-nystrom'). Use list_characters to find valid IDs."),
1752
2325
  fields: z.object({
@@ -1765,82 +2338,122 @@ export function registerMetadataTools(s, {
1765
2338
  if (!char) {
1766
2339
  return errorResponse("NOT_FOUND", `Character '${character_id}' not found.`);
1767
2340
  }
2341
+ const beforeTraits = db.prepare(`SELECT trait FROM character_traits WHERE character_id = ? ORDER BY trait`)
2342
+ .all(character_id).map(row => row.trait);
2343
+ const nextCharacter = {
2344
+ name: fields.name ?? char.name,
2345
+ role: Object.hasOwn(fields, "role") ? fields.role ?? null : char.role,
2346
+ arc_summary: Object.hasOwn(fields, "arc_summary") ? fields.arc_summary ?? null : char.arc_summary,
2347
+ first_appearance: Object.hasOwn(fields, "first_appearance") ? fields.first_appearance ?? null : char.first_appearance,
2348
+ };
2349
+ const nextTraits = fields.traits ? uniqueSorted(fields.traits) : beforeTraits;
1768
2350
  try {
1769
- const { meta } = readMeta(char.file_path, SYNC_DIR, { writable: true });
1770
- const updated = { ...meta, ...fields };
1771
- writeMeta(char.file_path, updated, { syncDir: SYNC_DIR });
1772
-
2351
+ db.exec("BEGIN");
1773
2352
  db.prepare(`
1774
2353
  UPDATE characters SET name = ?, role = ?, arc_summary = ?, first_appearance = ?
1775
2354
  WHERE character_id = ?
1776
2355
  `).run(
1777
- updated.name ?? meta.name, updated.role ?? null,
1778
- updated.arc_summary ?? null, updated.first_appearance ?? null,
2356
+ nextCharacter.name, nextCharacter.role,
2357
+ nextCharacter.arc_summary, nextCharacter.first_appearance,
1779
2358
  character_id
1780
2359
  );
1781
2360
  if (fields.traits) {
1782
2361
  db.prepare(`DELETE FROM character_traits WHERE character_id = ?`).run(character_id);
1783
- for (const t of fields.traits) {
2362
+ for (const t of nextTraits) {
1784
2363
  db.prepare(`INSERT OR IGNORE INTO character_traits (character_id, trait) VALUES (?, ?)`).run(character_id, t);
1785
2364
  }
1786
2365
  }
1787
- const backupResult = refreshProjectScopedBackupAfterMutation(db, {
1788
- syncDir: SYNC_DIR,
1789
- projectId: char.project_id,
1790
- applicationVersion: MCP_SERVER_VERSION,
1791
- operation: "update_character_sheet",
1792
- actor: createToolActor("update_character_sheet"),
1793
- affected: {
1794
- characters: [character_id],
1795
- },
1796
- summary: `Updated character sheet "${character_id}".`,
1797
- before: {
1798
- character: {
1799
- character_id,
1800
- project_id: char.project_id,
1801
- universe_id: char.universe_id,
1802
- name: char.name,
1803
- role: char.role,
1804
- arc_summary: char.arc_summary,
1805
- first_appearance: char.first_appearance,
1806
- },
2366
+ db.exec("COMMIT");
2367
+ } catch (err) {
2368
+ try {
2369
+ db.exec("ROLLBACK");
2370
+ } catch (rollbackErr) {
2371
+ void rollbackErr;
2372
+ }
2373
+ return errorResponse("IO_ERROR", `Failed to update canonical character metadata for '${character_id}': ${err.message}`);
2374
+ }
2375
+
2376
+ const backupResult = refreshProjectScopedBackupAfterMutation(db, {
2377
+ syncDir: SYNC_DIR,
2378
+ projectId: char.project_id,
2379
+ applicationVersion: MCP_SERVER_VERSION,
2380
+ operation: "update_character_sheet",
2381
+ actor: createToolActor("update_character_sheet"),
2382
+ affected: {
2383
+ characters: [character_id],
2384
+ },
2385
+ summary: `Updated character sheet "${character_id}".`,
2386
+ before: {
2387
+ character: {
2388
+ character_id,
2389
+ project_id: char.project_id,
2390
+ universe_id: char.universe_id,
2391
+ name: char.name,
2392
+ role: char.role,
2393
+ arc_summary: char.arc_summary,
2394
+ first_appearance: char.first_appearance,
2395
+ traits: beforeTraits,
1807
2396
  },
1808
- after: {
1809
- character: {
1810
- character_id,
1811
- project_id: char.project_id,
1812
- universe_id: char.universe_id,
1813
- name: updated.name ?? meta.name ?? null,
1814
- role: updated.role ?? null,
1815
- arc_summary: updated.arc_summary ?? null,
1816
- first_appearance: updated.first_appearance ?? null,
1817
- },
2397
+ },
2398
+ after: {
2399
+ character: {
2400
+ character_id,
2401
+ project_id: char.project_id,
2402
+ universe_id: char.universe_id,
2403
+ ...nextCharacter,
2404
+ traits: nextTraits,
1818
2405
  },
1819
- });
2406
+ },
2407
+ });
1820
2408
 
1821
- return jsonResponse({
1822
- ok: true,
1823
- action: "updated",
1824
- message: `Updated character sheet for '${character_id}'.`,
1825
- character_id,
1826
- ...backupMutationFields(backupResult),
1827
- });
1828
- } catch (err) {
1829
- if (err?.name === "CoreValidationError") {
1830
- return errorResponse(err.code, err.message, err.details);
1831
- }
1832
- if (err.code === "ENOENT") {
1833
- return errorResponse("STALE_PATH", `Character file for '${character_id}' not found at indexed path — the file may have moved. Run sync() to refresh.`, { indexed_path: char.file_path });
2409
+ const compatibilityDiagnostics = [];
2410
+ try {
2411
+ if (!char.file_path) {
2412
+ throw Object.assign(new Error("character has no indexed file path"), { code: "STALE_PATH" });
1834
2413
  }
1835
- return errorResponse("IO_ERROR", `Failed to write character metadata for '${character_id}': ${err.message}`);
2414
+ const { meta } = readMeta(char.file_path, SYNC_DIR, { writable: true });
2415
+ writeMeta(char.file_path, {
2416
+ ...meta,
2417
+ ...nextCharacter,
2418
+ traits: nextTraits,
2419
+ }, { syncDir: SYNC_DIR });
2420
+ } catch (err) {
2421
+ compatibilityDiagnostics.push({
2422
+ code: err?.code ?? "COMPATIBILITY_OUTPUT_FAILED",
2423
+ severity: "warning",
2424
+ message: `Canonical character metadata was committed, but generated compatibility output for '${character_id}' could not be refreshed: ${err.message}`,
2425
+ next_step: "Treat SQLite and project backup artifacts as current. Run sync and inspect the indexed character path before retrying compatibility output.",
2426
+ details: {
2427
+ character_id,
2428
+ indexed_path: char.file_path,
2429
+ },
2430
+ });
1836
2431
  }
2432
+
2433
+ return jsonResponse({
2434
+ ok: true,
2435
+ action: "updated",
2436
+ message: `Updated character sheet for '${character_id}'.`,
2437
+ character_id,
2438
+ mutation_order: [
2439
+ "validated_request",
2440
+ "sqlite_commit",
2441
+ "project_backup_refresh",
2442
+ "compatibility_output_refresh",
2443
+ ],
2444
+ compatibility_output: buildCompatibilityOutput({
2445
+ refreshed: compatibilityDiagnostics.length === 0,
2446
+ diagnostics: compatibilityDiagnostics,
2447
+ }),
2448
+ ...backupMutationFields(backupResult),
2449
+ });
1837
2450
  }
1838
2451
  );
1839
2452
 
1840
2453
  // ---- update_place_sheet --------------------------------------------------
1841
2454
  s.tool(
1842
2455
  "update_place_sheet",
1843
- "Update structured metadata fields for a place (name, associated_characters, tags). Writes to the .meta.yaml sidecar never modifies the prose notes file. Changes are immediately reflected in the index. Only available when the sync dir is writable.",
2456
+ "Update canonical place profile fields and retained compatibility notes. The place name commits to SQLite first and refreshes project backups; associated_characters and tags are compatibility/review metadata only. For current character/place relationship authority, use connect_character_place_evidence.",
1844
2457
  {
1845
2458
  place_id: z.string().describe("The place_id to update (e.g. 'place-harbor-district'). Use list_places to find valid IDs."),
1846
2459
  fields: z.object({
@@ -1857,14 +2470,25 @@ export function registerMetadataTools(s, {
1857
2470
  if (!place) {
1858
2471
  return errorResponse("NOT_FOUND", `Place '${place_id}' not found.`);
1859
2472
  }
1860
- try {
1861
- const { meta } = readMeta(place.file_path, SYNC_DIR, { writable: true });
1862
- const updated = { ...meta, ...fields };
1863
- writeMeta(place.file_path, updated, { syncDir: SYNC_DIR });
2473
+ const hasCanonicalNameUpdate = Object.hasOwn(fields, "name");
2474
+ const nextName = fields.name ?? place.name;
2475
+ let backupResult = emptyBackupMutationResult();
1864
2476
 
1865
- db.prepare(`UPDATE places SET name = ? WHERE place_id = ?`)
1866
- .run(updated.name ?? meta.name ?? place_id, place_id);
1867
- const backupResult = refreshProjectScopedBackupAfterMutation(db, {
2477
+ if (hasCanonicalNameUpdate) {
2478
+ try {
2479
+ db.exec("BEGIN");
2480
+ db.prepare(`UPDATE places SET name = ? WHERE place_id = ?`)
2481
+ .run(nextName, place_id);
2482
+ db.exec("COMMIT");
2483
+ } catch (err) {
2484
+ try {
2485
+ db.exec("ROLLBACK");
2486
+ } catch (rollbackErr) {
2487
+ void rollbackErr;
2488
+ }
2489
+ return errorResponse("IO_ERROR", `Failed to update canonical place metadata for '${place_id}': ${err.message}`);
2490
+ }
2491
+ backupResult = refreshProjectScopedBackupAfterMutation(db, {
1868
2492
  syncDir: SYNC_DIR,
1869
2493
  projectId: place.project_id,
1870
2494
  applicationVersion: MCP_SERVER_VERSION,
@@ -1887,34 +2511,68 @@ export function registerMetadataTools(s, {
1887
2511
  place_id,
1888
2512
  project_id: place.project_id,
1889
2513
  universe_id: place.universe_id,
1890
- name: updated.name ?? meta.name ?? place_id,
2514
+ name: nextName,
1891
2515
  },
1892
2516
  },
1893
2517
  });
2518
+ }
1894
2519
 
1895
- return jsonResponse({
1896
- ok: true,
1897
- action: "updated",
1898
- message: `Updated place sheet for '${place_id}'.`,
1899
- place_id,
1900
- ...backupMutationFields(backupResult),
1901
- });
1902
- } catch (err) {
1903
- if (err?.name === "CoreValidationError") {
1904
- return errorResponse(err.code, err.message, err.details);
1905
- }
1906
- if (err.code === "ENOENT") {
1907
- return errorResponse("STALE_PATH", `Place file for '${place_id}' not found at indexed path — the file may have moved. Run sync() to refresh.`, { indexed_path: place.file_path });
2520
+ const compatibilityDiagnostics = [];
2521
+ try {
2522
+ if (!place.file_path) {
2523
+ throw Object.assign(new Error("place has no indexed file path"), { code: "STALE_PATH" });
1908
2524
  }
1909
- return errorResponse("IO_ERROR", `Failed to write place metadata for '${place_id}': ${err.message}`);
2525
+ const { meta } = readMeta(place.file_path, SYNC_DIR, { writable: true });
2526
+ const updated = { ...meta, ...fields, name: nextName };
2527
+ writeMeta(place.file_path, updated, { syncDir: SYNC_DIR });
2528
+ } catch (err) {
2529
+ compatibilityDiagnostics.push({
2530
+ code: err?.code ?? "COMPATIBILITY_OUTPUT_FAILED",
2531
+ severity: "warning",
2532
+ message: `Place metadata was updated, but generated compatibility output for '${place_id}' could not be refreshed: ${err.message}`,
2533
+ next_step: "Treat SQLite and project backup artifacts as current for canonical place fields. Use connect_character_place_evidence for relationship authority.",
2534
+ details: {
2535
+ place_id,
2536
+ indexed_path: place.file_path,
2537
+ },
2538
+ });
1910
2539
  }
2540
+
2541
+ return jsonResponse({
2542
+ ok: true,
2543
+ action: "updated",
2544
+ message: `Updated place sheet for '${place_id}'.`,
2545
+ place_id,
2546
+ canonical_mutation: hasCanonicalNameUpdate,
2547
+ mutation_order: hasCanonicalNameUpdate
2548
+ ? [
2549
+ "validated_request",
2550
+ "sqlite_commit",
2551
+ "project_backup_refresh",
2552
+ "compatibility_output_refresh",
2553
+ ]
2554
+ : [
2555
+ "validated_request",
2556
+ "compatibility_review_note_refresh",
2557
+ ],
2558
+ compatibility_output: buildCompatibilityOutput({
2559
+ refreshed: compatibilityDiagnostics.length === 0,
2560
+ diagnostics: compatibilityDiagnostics,
2561
+ role: hasCanonicalNameUpdate ? "generated_transparency" : "review_note",
2562
+ }),
2563
+ non_canonical_fields: ["associated_characters", "tags"].filter(field => Object.hasOwn(fields, field)),
2564
+ next_step: Object.hasOwn(fields, "associated_characters")
2565
+ ? "Use connect_character_place_evidence to make scene-backed character/place associations authoritative."
2566
+ : undefined,
2567
+ ...backupMutationFields(backupResult),
2568
+ });
1911
2569
  }
1912
2570
  );
1913
2571
 
1914
2572
  // ---- flag_scene ----------------------------------------------------------
1915
2573
  s.tool(
1916
2574
  "flag_scene",
1917
- "Attach a continuity or review note to a scene. Flags are appended to the sidecar file and accumulate over time they are never overwritten. Use this to record continuity problems, revision notes, or questions you want to revisit.",
2575
+ "Attach a continuity or review note to a scene as compatibility review metadata. Flags are not canonical relationship authority and do not mutate SQLite; use audit_relationship_metadata, connect_character_place_evidence, record_character_relationship_beat, or link_reference_evidence when the note identifies relationship repair work.",
1918
2576
  {
1919
2577
  scene_id: z.string().describe("The scene_id to flag (e.g. 'sc-012-open-to-anyone')."),
1920
2578
  project_id: z.string().describe("Project the scene belongs to (e.g. 'the-lamb')."),
@@ -1934,7 +2592,21 @@ export function registerMetadataTools(s, {
1934
2592
  const flags = sourceMeta.flags ?? [];
1935
2593
  flags.push({ note, flagged_at: new Date().toISOString() });
1936
2594
  writeMeta(scene.file_path, { ...sourceMeta, flags }, { syncDir: SYNC_DIR });
1937
- return { content: [{ type: "text", text: `Flagged scene '${scene_id}': ${note}` }] };
2595
+ return jsonResponse({
2596
+ ok: true,
2597
+ action: "flagged",
2598
+ message: `Flagged scene '${scene_id}': ${note}`,
2599
+ scene_id,
2600
+ project_id,
2601
+ compatibility_output: {
2602
+ role: "review_note",
2603
+ generated_transparency: false,
2604
+ mutation_surface: false,
2605
+ canonical_mutation: false,
2606
+ refreshed: true,
2607
+ },
2608
+ next_step: "If this flag identifies relationship drift, use audit_relationship_metadata before applying an outcome-level repair.",
2609
+ });
1938
2610
  } catch (err) {
1939
2611
  if (err?.name === "CoreValidationError") {
1940
2612
  return errorResponse(err.code, err.message, err.details);