@hanna84/mcp-writing 3.23.0 → 3.23.2

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.
package/CHANGELOG.md CHANGED
@@ -4,9 +4,23 @@ All notable changes to this project will be documented in this file. Dates are d
4
4
 
5
5
  Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
6
6
 
7
+ #### [v3.23.2](https://github.com/hannasdev/mcp-writing/compare/v3.23.1...v3.23.2)
8
+
9
+ - docs: clarify relationship workflow guidance [`#232`](https://github.com/hannasdev/mcp-writing/pull/232)
10
+
11
+ #### [v3.23.1](https://github.com/hannasdev/mcp-writing/compare/v3.23.0...v3.23.1)
12
+
13
+ > 30 May 2026
14
+
15
+ - fix: align relationship compatibility sync and audit [`#231`](https://github.com/hannasdev/mcp-writing/pull/231)
16
+ - Release 3.23.1 [`f0b643b`](https://github.com/hannasdev/mcp-writing/commit/f0b643bcb7b09adf066380d37718b09001dd3020)
17
+
7
18
  #### [v3.23.0](https://github.com/hannasdev/mcp-writing/compare/v3.22.5...v3.23.0)
8
19
 
20
+ > 30 May 2026
21
+
9
22
  - feat: guard scene relationship metadata updates [`#230`](https://github.com/hannasdev/mcp-writing/pull/230)
23
+ - Release 3.23.0 [`93c359f`](https://github.com/hannasdev/mcp-writing/commit/93c359f71dde69afed34e7b024aa21729810df2b)
10
24
 
11
25
  #### [v3.22.5](https://github.com/hannasdev/mcp-writing/compare/v3.22.4...v3.22.5)
12
26
 
package/README.md CHANGED
@@ -30,7 +30,7 @@ Instead of feeding an entire manuscript to an AI and hoping it fits in the conte
30
30
  - **Core platform complete:** Metadata-first analysis, SQLite-canonical structural and relationship metadata, compatibility sidecar maintenance, AI-assisted prose editing with confirmation + git history, review bundles, and Scrivener Direct extraction are all implemented.
31
31
  - **Recently completed:** Database Backup and Recovery added project backup export, freshness diagnostics, advisory operation history, automatic backup refresh after sanctioned project-scoped canonical mutations, dry-run restore planning, transactional restore application, and backup/restore operations guidance.
32
32
  - **Previous milestone:** Docker, CI, and Deployment Workflow made Docker a supported way to build, run, smoke-test, and deploy Writing MCP.
33
- - **Active development:** Architecture Alignment Follow-up M5, documenting sidecar compatibility, migration, and deprecation expectations.
33
+ - **Active development:** Relationship Metadata Boundary M3, aligning workflow and generated documentation around SQLite-canonical relationship authority.
34
34
  - **Deferred backlog:** OpenClaw integration, client-agnostic setup, divisions, and embeddings search.
35
35
  - **Ideas and open questions:** tracked separately so future exploration does not distort the active roadmap.
36
36
 
@@ -153,7 +153,7 @@ Outcome: subplot structure stays visible and auditable, which reduces dropped th
153
153
  Goal: keep indexes accurate without manually re-tagging everything.
154
154
 
155
155
  1. After rewriting scenes, call `enrich_scene` to re-derive lightweight metadata from current prose.
156
- 2. Use `update_scene_metadata` for intentional editorial fields (for example, beat, POV, status, and tags); use `list_chapters` plus `assign_scene_to_chapter` or `move_scene` for chapter placement and ordering. Numeric chapter filters are compatibility aliases for read scopes, not mutation targets.
156
+ 2. Use `update_scene_metadata` for intentional editorial fields (for example, beat, POV, status, and tags). It rejects scene `characters` and `places`; use `connect_character_place_evidence` when a scene proves paired sheet-backed character/place evidence, and keep one-sided scene links for a deliberately named relationship workflow. Use `audit_relationship_metadata` for retained sidecar/frontmatter relationship fields. Use `list_chapters` plus `assign_scene_to_chapter` or `move_scene` for chapter placement and ordering.
157
157
  3. Use `search_metadata` and `find_scenes` to verify scenes are discoverable under the expected filters.
158
158
 
159
159
  Outcome: your AI assistant can reliably find the right scenes without drifting from the manuscript.
@@ -179,7 +179,7 @@ Goal: rebuild scene-to-character links in a controlled way after imported prose
179
179
  4. Re-run `enrich_scene_characters_batch` with `dry_run=false` once the preview looks correct.
180
180
  5. If you want a destructive overwrite instead of additive merge behavior, use `replace_mode=replace` with `confirm_replace=true` deliberately.
181
181
 
182
- Outcome: character-link maintenance becomes a preview-first batch operation instead of a one-off regex script or manual sidecar cleanup.
182
+ Outcome: character-link maintenance becomes a preview-first relationship repair operation instead of a one-off regex script or manual sidecar cleanup.
183
183
 
184
184
  ### 6) Post-upgrade recovery after legacy migration warnings
185
185
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanna84/mcp-writing",
3
- "version": "3.23.0",
3
+ "version": "3.23.2",
4
4
  "description": "MCP service for AI-assisted reasoning and editing on long-form fiction projects",
5
5
  "homepage": "https://hannasdev.github.io/mcp-writing/",
6
6
  "type": "module",
@@ -57,6 +57,16 @@ function resolveCharacterEntry(entry, characterRows) {
57
57
  return resolveCharacterReference(entry, characterRows);
58
58
  }
59
59
 
60
+ function normalizeCompatibilityCharacters(value) {
61
+ const rawValues = Array.isArray(value)
62
+ ? value
63
+ : typeof value === "string"
64
+ ? value.split(",")
65
+ : [];
66
+
67
+ return [...new Set(rawValues.map(String).map(character => character.trim()).filter(Boolean))];
68
+ }
69
+
60
70
  function pruneLessSpecificCharacters(characterIds, fullNameMatches, characterRows) {
61
71
  const kept = new Set(characterIds);
62
72
 
@@ -140,7 +150,7 @@ export async function runSceneCharacterBatch({ syncDir, args, onProgress, should
140
150
  const { content: prose } = matter(raw);
141
151
  const { sourceMeta: meta } = readSourceMeta(scene.file_path, syncDir, { writable: !dry_run });
142
152
 
143
- const before_characters = [...new Set((meta.characters ?? []).map(String).filter(Boolean))];
153
+ const before_characters = normalizeCompatibilityCharacters(meta.characters);
144
154
  const normalized_before_characters = [...new Set(
145
155
  before_characters
146
156
  .map(character => resolveCharacterEntry(character, normalizedCharacterRows))
package/src/sync/sync.js CHANGED
@@ -299,6 +299,24 @@ export function normalizeReferenceIdList(values) {
299
299
  )];
300
300
  }
301
301
 
302
+ function normalizeMetadataList(values) {
303
+ const rawValues = Array.isArray(values)
304
+ ? values
305
+ : typeof values === "string"
306
+ ? values.split(",")
307
+ : [];
308
+
309
+ return [...new Set(
310
+ rawValues
311
+ .map(value => String(value).trim())
312
+ .filter(Boolean)
313
+ )];
314
+ }
315
+
316
+ function isVersionContinuityMarker(value) {
317
+ return /^v\d[\d.a-z]*$/i.test(String(value).trim());
318
+ }
319
+
302
320
  function normalizeReferenceRelation(value, fallbackRelation) {
303
321
  const normalized = String(value ?? fallbackRelation ?? "").trim().toLowerCase();
304
322
  if (/^[a-z][a-z0-9_-]*$/.test(normalized)) return normalized;
@@ -1272,7 +1290,131 @@ export function readSceneFileForSync(syncDir, file, { writable = false } = {}) {
1272
1290
  };
1273
1291
  }
1274
1292
 
1275
- export function indexSceneFile(db, syncDir, file, meta, prose, { observedStructure, managedStructure = false } = {}) {
1293
+ export function resolveSceneCharacterCompatibilityId(db, value) {
1294
+ let characterId = value;
1295
+ if (!/^char-/.test(value)) {
1296
+ let row = db.prepare(`SELECT character_id FROM characters WHERE lower(name) = lower(?)`).get(value);
1297
+ if (!row) {
1298
+ const words = value.toLowerCase().split(/\s+/).filter(Boolean);
1299
+ const all = db.prepare(`SELECT character_id, name FROM characters`).all();
1300
+ const match = all.find(r =>
1301
+ words.every(w => r.name.toLowerCase().includes(w))
1302
+ );
1303
+ if (match) row = match;
1304
+ }
1305
+ if (row) characterId = row.character_id;
1306
+ }
1307
+ return characterId;
1308
+ }
1309
+
1310
+ function querySceneRelationshipIds(db, { sceneId, projectId, table, idColumn }) {
1311
+ return db.prepare(`
1312
+ SELECT ${idColumn} AS id
1313
+ FROM ${table}
1314
+ WHERE scene_id = ? AND project_id = ?
1315
+ ORDER BY ${idColumn}
1316
+ `).all(sceneId, projectId).map((row) => row.id);
1317
+ }
1318
+
1319
+ function sameStringSet(a, b) {
1320
+ const left = [...new Set(a.map(String))].sort();
1321
+ const right = [...new Set(b.map(String))].sort();
1322
+ return left.length === right.length && left.every((value, index) => value === right[index]);
1323
+ }
1324
+
1325
+ function selectRelationshipIndexValues({ sceneId, projectId, relationshipKind, existing, compatibility, compatibilityMode, hasCompatibilityField, hasExistingScene }) {
1326
+ if (!hasCompatibilityField) {
1327
+ return {
1328
+ values: existing,
1329
+ diagnostic: null,
1330
+ };
1331
+ }
1332
+ if (compatibilityMode === "adopt_compatibility") {
1333
+ return {
1334
+ values: compatibility,
1335
+ diagnostic: null,
1336
+ };
1337
+ }
1338
+ if (hasExistingScene && !sameStringSet(existing, compatibility)) {
1339
+ return {
1340
+ values: existing,
1341
+ diagnostic: {
1342
+ type: "relationship_compatibility_drift",
1343
+ relationship_kind: relationshipKind,
1344
+ scene_id: sceneId,
1345
+ project_id: projectId,
1346
+ canonical_values: existing,
1347
+ compatibility_values: compatibility,
1348
+ message: `Relationship compatibility drift for scene "${sceneId}" in project "${projectId}": sidecar ${relationshipKind} differ from SQLite ${relationshipKind}; preserved SQLite relationship rows. Run audit_relationship_metadata before applying an outcome-level repair.`,
1349
+ },
1350
+ };
1351
+ }
1352
+ return {
1353
+ values: compatibility,
1354
+ diagnostic: null,
1355
+ };
1356
+ }
1357
+
1358
+ function relationshipCompatibilityFieldsFromMeta(meta = {}) {
1359
+ return {
1360
+ characters: Object.hasOwn(meta, "characters"),
1361
+ places: Object.hasOwn(meta, "places"),
1362
+ };
1363
+ }
1364
+
1365
+ function buildSceneRelationshipIndexPlan(db, { meta, projectId, compatibilityMode = "preserve_existing", compatibilityFields = relationshipCompatibilityFieldsFromMeta(meta), hasExistingScene = false }) {
1366
+ const compatibilityCharacters = [];
1367
+ const compatibilityVersionMarkers = [];
1368
+ for (const value of normalizeMetadataList(meta.characters)) {
1369
+ if (isVersionContinuityMarker(value)) {
1370
+ compatibilityVersionMarkers.push(value);
1371
+ continue;
1372
+ }
1373
+ compatibilityCharacters.push(resolveSceneCharacterCompatibilityId(db, value));
1374
+ }
1375
+ const compatibilityPlaces = normalizeMetadataList(meta.places);
1376
+ const existingCharacters = querySceneRelationshipIds(db, {
1377
+ sceneId: meta.scene_id,
1378
+ projectId,
1379
+ table: "scene_characters",
1380
+ idColumn: "character_id",
1381
+ });
1382
+ const existingPlaces = querySceneRelationshipIds(db, {
1383
+ sceneId: meta.scene_id,
1384
+ projectId,
1385
+ table: "scene_places",
1386
+ idColumn: "place_id",
1387
+ });
1388
+ const characterSelection = selectRelationshipIndexValues({
1389
+ sceneId: meta.scene_id,
1390
+ projectId,
1391
+ relationshipKind: "characters",
1392
+ existing: existingCharacters,
1393
+ compatibility: compatibilityCharacters,
1394
+ compatibilityMode,
1395
+ hasCompatibilityField: compatibilityFields.characters,
1396
+ hasExistingScene,
1397
+ });
1398
+ const placeSelection = selectRelationshipIndexValues({
1399
+ sceneId: meta.scene_id,
1400
+ projectId,
1401
+ relationshipKind: "places",
1402
+ existing: existingPlaces,
1403
+ compatibility: compatibilityPlaces,
1404
+ compatibilityMode,
1405
+ hasCompatibilityField: compatibilityFields.places,
1406
+ hasExistingScene,
1407
+ });
1408
+
1409
+ return {
1410
+ characters: characterSelection.values,
1411
+ places: placeSelection.values,
1412
+ compatibilityVersionMarkers,
1413
+ diagnostics: [characterSelection.diagnostic, placeSelection.diagnostic].filter(Boolean),
1414
+ };
1415
+ }
1416
+
1417
+ export function indexSceneFile(db, syncDir, file, meta, prose, { observedStructure, managedStructure = false, relationshipCompatibilityMode = "preserve_existing", relationshipCompatibilityFields } = {}) {
1276
1418
  const canonicalIndexPlan = buildCanonicalIndexPlan(db, syncDir, file, meta, observedStructure, { managedStructure });
1277
1419
  const { universeId: universe_id, projectId: project_id } = canonicalIndexPlan;
1278
1420
  const { chapterStructure } = canonicalIndexPlan.observedStructure;
@@ -1408,58 +1550,44 @@ export function indexSceneFile(db, syncDir, file, meta, prose, { observedStructu
1408
1550
  new Date().toISOString()
1409
1551
  );
1410
1552
 
1553
+ const relationshipIndexPlan = buildSceneRelationshipIndexPlan(db, {
1554
+ meta,
1555
+ projectId: project_id,
1556
+ compatibilityMode: relationshipCompatibilityMode,
1557
+ compatibilityFields: relationshipCompatibilityFields,
1558
+ hasExistingScene: Boolean(existing),
1559
+ });
1560
+ const tagValues = [
1561
+ ...normalizeMetadataList(meta.tags),
1562
+ ...relationshipIndexPlan.compatibilityVersionMarkers,
1563
+ ...normalizeMetadataList(meta.versions),
1564
+ ];
1565
+
1411
1566
  db.prepare(`DELETE FROM scene_characters WHERE scene_id = ? AND project_id = ?`).run(meta.scene_id, project_id);
1412
1567
  db.prepare(`DELETE FROM scene_places WHERE scene_id = ? AND project_id = ?`).run(meta.scene_id, project_id);
1413
1568
  db.prepare(`DELETE FROM scene_tags WHERE scene_id = ? AND project_id = ?`).run(meta.scene_id, project_id);
1569
+ db.prepare(`DELETE FROM scenes_fts WHERE scene_id = ? AND project_id = ?`).run(meta.scene_id, project_id);
1414
1570
 
1415
- for (const c of (meta.characters ?? [])) {
1416
- // Version continuity markers (e.g. v7.3, v3.3b) are tracked as tags, not characters
1417
- if (/^v\d[\d.a-z]*$/i.test(c)) {
1418
- db.prepare(`INSERT OR IGNORE INTO scene_tags (scene_id, project_id, tag) VALUES (?, ?, ?)`).run(meta.scene_id, project_id, c);
1419
- continue;
1420
- }
1421
- let cid = c;
1422
- // If the value looks like a name rather than an ID, try to resolve it
1423
- if (!/^char-/.test(c)) {
1424
- // 1. Exact name match (case-insensitive)
1425
- let row = db.prepare(`SELECT character_id FROM characters WHERE lower(name) = lower(?)`).get(c);
1426
- // 2. Word-overlap: all words in the keyword appear in the stored name
1427
- // Handles "Victor Sidorin" → "Victor Alexeyvich Sidorin"
1428
- if (!row) {
1429
- const words = c.toLowerCase().split(/\s+/).filter(Boolean);
1430
- const all = db.prepare(`SELECT character_id, name FROM characters`).all();
1431
- const match = all.find(r =>
1432
- words.every(w => r.name.toLowerCase().includes(w))
1433
- );
1434
- if (match) row = match;
1435
- }
1436
- if (row) cid = row.character_id;
1437
- }
1571
+ for (const cid of relationshipIndexPlan.characters) {
1438
1572
  db.prepare(`INSERT OR IGNORE INTO scene_characters (scene_id, project_id, character_id) VALUES (?, ?, ?)`).run(
1439
1573
  meta.scene_id, project_id, cid
1440
1574
  );
1441
1575
  }
1442
- for (const p of (meta.places ?? [])) {
1576
+ for (const p of relationshipIndexPlan.places) {
1443
1577
  db.prepare(`INSERT OR IGNORE INTO scene_places (scene_id, project_id, place_id) VALUES (?, ?, ?)`).run(
1444
1578
  meta.scene_id, project_id, p
1445
1579
  );
1446
1580
  }
1447
- for (const t of (meta.tags ?? [])) {
1581
+ for (const t of tagValues) {
1448
1582
  db.prepare(`INSERT OR IGNORE INTO scene_tags (scene_id, project_id, tag) VALUES (?, ?, ?)`).run(
1449
1583
  meta.scene_id, project_id, t
1450
1584
  );
1451
1585
  }
1452
- for (const v of (meta.versions ?? [])) {
1453
- db.prepare(`INSERT OR IGNORE INTO scene_tags (scene_id, project_id, tag) VALUES (?, ?, ?)`).run(
1454
- meta.scene_id, project_id, v
1455
- );
1456
- }
1457
1586
 
1458
1587
  const keywordTokens = [
1459
- ...(meta.tags ?? []),
1460
- ...(meta.characters ?? []),
1461
- ...(meta.places ?? []),
1462
- ...(meta.versions ?? []),
1588
+ ...tagValues,
1589
+ ...relationshipIndexPlan.characters,
1590
+ ...relationshipIndexPlan.places,
1463
1591
  ]
1464
1592
  .filter(Boolean)
1465
1593
  .map(String)
@@ -1467,7 +1595,7 @@ export function indexSceneFile(db, syncDir, file, meta, prose, { observedStructu
1467
1595
  .filter(Boolean)
1468
1596
  .join(" ");
1469
1597
 
1470
- db.prepare(`INSERT OR REPLACE INTO scenes_fts (scene_id, project_id, logline, title, keywords) VALUES (?, ?, ?, ?, ?)`).run(
1598
+ db.prepare(`INSERT INTO scenes_fts (scene_id, project_id, logline, title, keywords) VALUES (?, ?, ?, ?, ?)`).run(
1471
1599
  meta.scene_id,
1472
1600
  project_id,
1473
1601
  meta.logline ?? meta.synopsis ?? "",
@@ -1493,7 +1621,13 @@ export function indexSceneFile(db, syncDir, file, meta, prose, { observedStructu
1493
1621
  relation: "informs",
1494
1622
  });
1495
1623
 
1496
- return { isStale, chapterId, warning: chapterWarning, canonicalIndexPlan };
1624
+ return {
1625
+ isStale,
1626
+ chapterId,
1627
+ warning: chapterWarning,
1628
+ canonicalIndexPlan,
1629
+ relationshipCompatibilityDiagnostics: relationshipIndexPlan.diagnostics,
1630
+ };
1497
1631
  }
1498
1632
 
1499
1633
  export function isManagedStructureProject(db, projectId) {
@@ -1564,6 +1698,7 @@ const WARNING_TYPE_LABELS = {
1564
1698
  unobserved_canonical_scene: "Unobserved canonical scene",
1565
1699
  missing_canonical_chapter: "Missing canonical chapter",
1566
1700
  missing_canonical_epigraph: "Missing canonical epigraph",
1701
+ relationship_compatibility_drift: "Relationship compatibility drift",
1567
1702
  nested_mirror: "Ignored nested mirror path",
1568
1703
  };
1569
1704
 
@@ -1578,6 +1713,7 @@ const WARNING_PATTERNS = [
1578
1713
  { type: "unobserved_canonical_scene", re: /^Managed sync preserved canonical scene not observed during sync scan:/ },
1579
1714
  { type: "missing_canonical_chapter", re: /^Managed sync preserved canonical chapter missing from filesystem:/ },
1580
1715
  { type: "missing_canonical_epigraph", re: /^Managed sync preserved canonical epigraph missing from filesystem:/ },
1716
+ { type: "relationship_compatibility_drift", re: /^Relationship compatibility drift/ },
1581
1717
  { type: "nested_mirror", re: /^Ignored nested mirror path:/ },
1582
1718
  ];
1583
1719
 
@@ -1598,7 +1734,7 @@ export function buildWarningSummary(warnings) {
1598
1734
  return summary;
1599
1735
  }
1600
1736
 
1601
- export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
1737
+ export function syncAll(db, syncDir, { quiet = false, writable = false, relationshipCompatibilityMode = "preserve_existing" } = {}) {
1602
1738
  // Reset per-run inference cache so filesystem changes between sync calls
1603
1739
  // (for example after imports or path repairs) are reflected immediately.
1604
1740
  UNIVERSE_PROJECT_ROOT_CACHE.clear();
@@ -1701,6 +1837,8 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
1701
1837
  const result = indexSceneFile(db, syncDir, file, meta, prose, {
1702
1838
  observedStructure: structureObservation,
1703
1839
  managedStructure: managedProjectIds.has(project_id),
1840
+ relationshipCompatibilityMode,
1841
+ relationshipCompatibilityFields: relationshipCompatibilityFieldsFromMeta(sourceMeta),
1704
1842
  });
1705
1843
  const canonicalDiagnostics = result.canonicalIndexPlan?.diagnostics ?? [];
1706
1844
  if (canonicalDiagnostics.length) {
@@ -1708,6 +1846,9 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
1708
1846
  } else if (result.warning) {
1709
1847
  warnings.push(result.warning);
1710
1848
  }
1849
+ for (const diagnostic of result.relationshipCompatibilityDiagnostics ?? []) {
1850
+ warnings.push(diagnostic.message);
1851
+ }
1711
1852
  if (result.chapterId) {
1712
1853
  const chapterKey = `${result.chapterId}::${project_id}`;
1713
1854
  seenChapterKeys.add(chapterKey);
@@ -2,7 +2,7 @@ import { z } from "zod";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import matter from "gray-matter";
5
- import { readMeta, readSourceMeta, writeMeta, indexSceneFile, isManagedStructureProject, normalizeSceneMetaForPath } from "../sync/sync.js";
5
+ import { readMeta, readSourceMeta, writeMeta, indexSceneFile, isManagedStructureProject, normalizeSceneMetaForPath, resolveSceneCharacterCompatibilityId } from "../sync/sync.js";
6
6
  import { validateProjectId, validateUniverseId } from "../sync/importer.js";
7
7
  import { resolveValidatedChapterFilter } from "../core/chapter-resolution.js";
8
8
  import {
@@ -121,7 +121,7 @@ function buildRelationshipMetadataBoundaryDetails({ projectId, sceneId, blockedF
121
121
  boundary: "scene_relationship_metadata",
122
122
  relationship_tools: ["connect_character_place_evidence", "audit_relationship_metadata"],
123
123
  discovery_workflows: ["describe_workflows", "find_scenes", "list_characters", "list_places"],
124
- next_step: "Use find_scenes, list_characters, and list_places to identify stable IDs. Use connect_character_place_evidence only when the scene proves paired sheet-backed character/place evidence; use audit_relationship_metadata to review legacy sidecar/frontmatter relationship fields. Character-only or place-only scene evidence is not changed through update_scene_metadata.",
124
+ next_step: "Use find_scenes, list_characters, and list_places to identify stable IDs. Use connect_character_place_evidence when the scene proves paired sheet-backed character/place evidence; use audit_relationship_metadata to review legacy sidecar/frontmatter relationship fields. Independent character-only or place-only scene links remain valid, but are not changed through update_scene_metadata.",
125
125
  };
126
126
  }
127
127
 
@@ -454,6 +454,36 @@ function querySceneRelationshipSnapshot(db, { sceneId, projectId }) {
454
454
  };
455
455
  }
456
456
 
457
+ function sameStringSet(a, b) {
458
+ const left = [...new Set(a.map(String))].sort();
459
+ const right = [...new Set(b.map(String))].sort();
460
+ return left.length === right.length && left.every((value, index) => value === right[index]);
461
+ }
462
+
463
+ function normalizeSceneRelationshipCompatibilityFields(db, sourceMeta) {
464
+ const hasCharactersField = Object.hasOwn(sourceMeta, "characters");
465
+ const hasPlacesField = Object.hasOwn(sourceMeta, "places");
466
+ return {
467
+ has_relationship_fields: hasCharactersField || hasPlacesField,
468
+ has_characters_field: hasCharactersField,
469
+ has_places_field: hasPlacesField,
470
+ characters: normalizeStringList(sourceMeta.characters)
471
+ .filter((value) => !isVersionContinuityMarker(value))
472
+ .map((value) => resolveSceneCharacterCompatibilityId(db, value)),
473
+ places: normalizeStringList(sourceMeta.places),
474
+ };
475
+ }
476
+
477
+ function sceneRelationshipCompatibilityHasDrift(canonicalRelationships, compatibilityRelationships) {
478
+ return (
479
+ compatibilityRelationships.has_characters_field &&
480
+ !sameStringSet(canonicalRelationships.characters, compatibilityRelationships.characters)
481
+ ) || (
482
+ compatibilityRelationships.has_places_field &&
483
+ !sameStringSet(canonicalRelationships.places, compatibilityRelationships.places)
484
+ );
485
+ }
486
+
457
487
  function restoreSceneRelationshipSnapshot(db, { sceneId, projectId, snapshot }) {
458
488
  db.prepare(`DELETE FROM scene_characters WHERE scene_id = ? AND project_id = ?`).run(sceneId, projectId);
459
489
  db.prepare(`DELETE FROM scene_places WHERE scene_id = ? AND project_id = ?`).run(sceneId, projectId);
@@ -1061,6 +1091,39 @@ export function registerMetadataTools(s, {
1061
1091
  }
1062
1092
  try {
1063
1093
  const { sourceMeta } = readSourceMeta(scene.file_path, SYNC_DIR, { writable: false });
1094
+ const compatibilityRelationships = normalizeSceneRelationshipCompatibilityFields(db, sourceMeta);
1095
+ if (compatibilityRelationships.has_relationship_fields) {
1096
+ const canonicalRelationships = querySceneRelationshipSnapshot(db, {
1097
+ sceneId: scene.scene_id,
1098
+ projectId: scene.project_id,
1099
+ });
1100
+ diagnostics.push({
1101
+ type: "scene_relationship_compatibility_input",
1102
+ severity: "info",
1103
+ message: `Scene '${scene.scene_id}' retains sidecar/frontmatter character/place relationship fields as compatibility input; SQLite scene relationship rows remain canonical.`,
1104
+ scene_id: scene.scene_id,
1105
+ project_id: scene.project_id,
1106
+ compatibility: compatibilityRelationships,
1107
+ canonical: canonicalRelationships,
1108
+ authority: {
1109
+ canonical_owner: "SQLite scene_characters/scene_places",
1110
+ compatibility_mutation_surface: false,
1111
+ },
1112
+ next_step: "Use this as migration or review evidence only. Use outcome-level relationship tools for current repairs.",
1113
+ });
1114
+ if (sceneRelationshipCompatibilityHasDrift(canonicalRelationships, compatibilityRelationships)) {
1115
+ diagnostics.push({
1116
+ type: "scene_relationship_compatibility_drift",
1117
+ severity: "warning",
1118
+ message: `Scene '${scene.scene_id}' sidecar/frontmatter relationship fields disagree with canonical SQLite relationship rows.`,
1119
+ scene_id: scene.scene_id,
1120
+ project_id: scene.project_id,
1121
+ compatibility: compatibilityRelationships,
1122
+ canonical: canonicalRelationships,
1123
+ next_step: "Treat SQLite relationship rows as canonical. Use find_scenes, list_characters, and list_places to inspect stable IDs; use connect_character_place_evidence when evidence is paired, and leave independent character-only/place-only repairs to a deliberately named future workflow.",
1124
+ });
1125
+ }
1126
+ }
1064
1127
  if (sourceMeta.threads) {
1065
1128
  diagnostics.push({
1066
1129
  type: "sidecar_threads_compatibility_input",
@@ -1146,10 +1209,11 @@ export function registerMetadataTools(s, {
1146
1209
  summary: {
1147
1210
  diagnostics_count: diagnostics.length,
1148
1211
  stale_scene_count: diagnostics.filter(diagnostic => diagnostic.type === "stale_scene_relationship_index").length,
1212
+ compatibility_drift_count: diagnostics.filter(diagnostic => diagnostic.type === "scene_relationship_compatibility_drift").length,
1149
1213
  compatibility_note_count: diagnostics.filter(diagnostic => diagnostic.severity === "info").length,
1150
1214
  },
1151
1215
  next_steps: [
1152
- "Use connect_character_place_evidence for scene-backed character/place relationships.",
1216
+ "Use connect_character_place_evidence when scene-backed character/place evidence is paired; independent one-sided links remain valid but need a deliberately named workflow.",
1153
1217
  "Use record_character_relationship_beat for relationship arcs between characters.",
1154
1218
  "Use link_reference_evidence for explicit reference evidence.",
1155
1219
  "Use export_project_backup when a fresh recovery snapshot is needed.",
@@ -1376,7 +1440,7 @@ export function registerMetadataTools(s, {
1376
1440
  // ---- connect_character_place_evidence ------------------------------------
1377
1441
  s.tool(
1378
1442
  "connect_character_place_evidence",
1379
- "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.",
1443
+ "Connect a character and place as paired scene-backed story evidence. This outcome-level workflow covers sheet-backed character/place associations: SQLite scene relationship indexes commit first, project backups refresh after commit, and scene sidecar characters/places are refreshed only as generated compatibility output. Independent character-only or place-only scene links remain valid, but are not handled by update_scene_metadata and need a deliberately named workflow if promoted later.",
1380
1444
  {
1381
1445
  project_id: z.string().describe("Project the scene belongs to (e.g. 'the-lamb')."),
1382
1446
  scene_id: z.string().describe("Scene that provides the evidence for this character/place association."),
@@ -1406,7 +1470,7 @@ export function registerMetadataTools(s, {
1406
1470
  // ---- audit_relationship_metadata -----------------------------------------
1407
1471
  s.tool(
1408
1472
  "audit_relationship_metadata",
1409
- "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.",
1473
+ "Review relationship metadata authority, stale indexes, retained compatibility notes, and scene character/place sidecar drift 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.",
1410
1474
  {
1411
1475
  project_id: z.string().optional().describe("Optional project scope for the audit."),
1412
1476
  },
@@ -2280,7 +2344,7 @@ export function registerMetadataTools(s, {
2280
2344
  // ---- update_scene_metadata -----------------------------------------------
2281
2345
  s.tool(
2282
2346
  "update_scene_metadata",
2283
- "Update one or more non-structural, non-relationship metadata fields for a scene. Writes only supplied allowed fields to the .meta.yaml sidecar and preserves existing structural compatibility fields; it never modifies prose, mirrors path-derived structure, or changes scene character/place relationship authority. Structural fields (part, chapter, chapter_id, chapter_title, timeline_position) are rejected here; use list_chapters plus assign_scene_to_chapter, move_scene, rename_chapter, or reorder_chapter for structure changes. Relationship fields (characters, places) are rejected here; use discovery workflows plus connect_character_place_evidence for paired sheet-backed character/place evidence, and audit_relationship_metadata for legacy sidecar/frontmatter relationship review. Allowed changes are immediately reflected in the index. Only available when the sync dir is writable.",
2347
+ "Update one or more non-structural, non-relationship metadata fields for a scene. Writes only supplied allowed fields to the .meta.yaml sidecar and preserves existing structural compatibility fields; it never modifies prose, mirrors path-derived structure, or changes scene character/place relationship authority. Structural fields (part, chapter, chapter_id, chapter_title, timeline_position) are rejected here; use list_chapters plus assign_scene_to_chapter, move_scene, rename_chapter, or reorder_chapter for structure changes. Relationship fields (characters, places) are rejected here; use discovery workflows plus connect_character_place_evidence when evidence is paired, and audit_relationship_metadata for legacy sidecar/frontmatter relationship review. Independent character-only or place-only scene links remain valid, but are intentionally not changed through this generic metadata tool. Allowed changes are immediately reflected in the index. Only available when the sync dir is writable.",
2284
2348
  {
2285
2349
  scene_id: z.string().describe("The scene_id to update (e.g. 'sc-011-sebastian')."),
2286
2350
  project_id: z.string().describe("Project the scene belongs to (e.g. 'the-lamb')."),
@@ -2297,8 +2361,8 @@ export function registerMetadataTools(s, {
2297
2361
  timeline_position: z.number().int().optional().describe("Rejected by update_scene_metadata. Use move_scene for ordering changes."),
2298
2362
  story_time: z.string().optional(),
2299
2363
  tags: z.array(z.string()).optional(),
2300
- characters: z.array(z.string()).optional().describe("Rejected by update_scene_metadata. Use find_scenes, list_characters, list_places, connect_character_place_evidence for paired sheet-backed evidence, and audit_relationship_metadata for compatibility review."),
2301
- places: z.array(z.string()).optional().describe("Rejected by update_scene_metadata. Use find_scenes, list_characters, list_places, connect_character_place_evidence for paired sheet-backed evidence, and audit_relationship_metadata for compatibility review."),
2364
+ characters: z.array(z.string()).optional().describe("Rejected by update_scene_metadata. Use find_scenes, list_characters, list_places, connect_character_place_evidence when evidence is paired, and audit_relationship_metadata for compatibility review; independent character-only evidence remains valid but needs a deliberately named workflow."),
2365
+ places: z.array(z.string()).optional().describe("Rejected by update_scene_metadata. Use find_scenes, list_characters, list_places, connect_character_place_evidence when evidence is paired, and audit_relationship_metadata for compatibility review; independent place-only evidence remains valid but needs a deliberately named workflow."),
2302
2366
  }).describe("Fields to update. Only supplied keys are changed."),
2303
2367
  },
2304
2368
  async ({ scene_id, project_id, fields }) => {
@@ -2543,7 +2607,7 @@ export function registerMetadataTools(s, {
2543
2607
  // ---- update_place_sheet --------------------------------------------------
2544
2608
  s.tool(
2545
2609
  "update_place_sheet",
2546
- "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.",
2610
+ "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. Use connect_character_place_evidence when scene-backed character/place evidence is paired; independent place-only links remain valid but need a deliberately named workflow.",
2547
2611
  {
2548
2612
  place_id: z.string().describe("The place_id to update (e.g. 'place-harbor-district'). Use list_places to find valid IDs."),
2549
2613
  fields: z.object({
@@ -2652,7 +2716,7 @@ export function registerMetadataTools(s, {
2652
2716
  }),
2653
2717
  non_canonical_fields: ["associated_characters", "tags"].filter(field => Object.hasOwn(fields, field)),
2654
2718
  next_step: Object.hasOwn(fields, "associated_characters")
2655
- ? "Use connect_character_place_evidence to make scene-backed character/place associations authoritative."
2719
+ ? "Use connect_character_place_evidence when paired scene-backed character/place evidence should become authoritative; independent one-sided links remain valid but need a deliberately named workflow."
2656
2720
  : undefined,
2657
2721
  ...backupMutationFields(backupResult),
2658
2722
  });
@@ -609,7 +609,7 @@ export function registerSearchTools(s, {
609
609
  // ---- get_place_sheet -----------------------------------------------------
610
610
  s.tool(
611
611
  "get_place_sheet",
612
- "Get full place details, including canonical sheet content plus retained sidecar associated_characters and tags as compatibility/review notes. Use connect_character_place_evidence for current scene-backed character/place authority. Response shape note: returns a structured envelope (`results`, `total_count`) with one result row.",
612
+ "Get full place details, including canonical sheet content plus retained sidecar associated_characters and tags as compatibility/review notes. Use connect_character_place_evidence when scene-backed character/place evidence is paired; independent place-only links remain valid and need an explicit relationship workflow rather than a place-sheet read. Response shape note: returns a structured envelope (`results`, `total_count`) with one result row.",
613
613
  {
614
614
  place_id: z.string().describe("The place_id to look up (e.g. 'place-harbor-district'). Use list_places to find valid IDs."),
615
615
  },
package/src/tools/sync.js CHANGED
@@ -470,7 +470,10 @@ export function registerSyncTools(s, {
470
470
 
471
471
  let syncResult = null;
472
472
  if (!dry_run && !preflight && auto_sync) {
473
- syncResult = syncAll(db, SYNC_DIR, { writable: SYNC_DIR_WRITABLE });
473
+ syncResult = syncAll(db, SYNC_DIR, {
474
+ writable: SYNC_DIR_WRITABLE,
475
+ relationshipCompatibilityMode: "adopt_compatibility",
476
+ });
474
477
  }
475
478
 
476
479
  return jsonResponse({
@@ -572,7 +575,10 @@ export function registerSyncTools(s, {
572
575
  },
573
576
  onComplete: (completedJob) => {
574
577
  if (!auto_sync || dry_run || preflight || completedJob.status !== "completed") return;
575
- const syncResult = syncAll(db, SYNC_DIR, { writable: SYNC_DIR_WRITABLE });
578
+ const syncResult = syncAll(db, SYNC_DIR, {
579
+ writable: SYNC_DIR_WRITABLE,
580
+ relationshipCompatibilityMode: "adopt_compatibility",
581
+ });
576
582
  if (completedJob.result && completedJob.result.ok) {
577
583
  completedJob.result.sync = {
578
584
  indexed: syncResult.indexed,
@@ -652,7 +658,10 @@ export function registerSyncTools(s, {
652
658
  },
653
659
  onComplete: (completedJob) => {
654
660
  if (!auto_sync || dry_run || completedJob.status !== "completed") return;
655
- const syncResult = syncAll(db, SYNC_DIR, { writable: SYNC_DIR_WRITABLE });
661
+ const syncResult = syncAll(db, SYNC_DIR, {
662
+ writable: SYNC_DIR_WRITABLE,
663
+ relationshipCompatibilityMode: "adopt_compatibility",
664
+ });
656
665
  if (completedJob.result && completedJob.result.ok) {
657
666
  completedJob.result.sync = {
658
667
  indexed: syncResult.indexed,
@@ -776,7 +785,10 @@ export function registerSyncTools(s, {
776
785
  onComplete: (completedJob) => {
777
786
  if (dry_run || completedJob.status !== "completed" || !completedJob.result?.ok) return;
778
787
 
779
- syncAll(db, SYNC_DIR, { writable: SYNC_DIR_WRITABLE });
788
+ syncAll(db, SYNC_DIR, {
789
+ writable: SYNC_DIR_WRITABLE,
790
+ relationshipCompatibilityMode: "adopt_compatibility",
791
+ });
780
792
 
781
793
  const changedScenes = (completedJob.result.results ?? [])
782
794
  .filter(row => row.status === "changed")
@@ -980,6 +992,7 @@ export function registerSyncTools(s, {
980
992
  writeMeta(scene.file_path, sourceUpdatedMeta, { syncDir: SYNC_DIR });
981
993
  indexSceneFile(db, SYNC_DIR, scene.file_path, updatedMeta, prose, {
982
994
  managedStructure: isManagedStructureProject(db, scene.project_id),
995
+ relationshipCompatibilityMode: "adopt_compatibility",
983
996
  });
984
997
  db.prepare(`UPDATE scenes SET metadata_stale = 0 WHERE scene_id = ? AND project_id = ?`)
985
998
  .run(scene.scene_id, scene.project_id);
@@ -68,12 +68,12 @@ export const WORKFLOW_CATALOGUE = [
68
68
  label: "Track and repair story relationships",
69
69
  use_when: "Use when the user wants to track an arc, link evidence, review stale relationships, repair metadata parity, or prepare generated recovery material for relationship metadata.",
70
70
  steps: [
71
- { tool: "find_scenes", note: "Identify scene_id and project_id from story context before recording relationships." },
71
+ { tool: "find_scenes", note: "Identify scene_id and project_id from story context before recording relationships; use list_characters and list_places when stable entity IDs need disambiguation." },
72
72
  { tool: "track_thread_arc", note: "Use when a scene should carry a storyline, subplot, setup, escalation, reveal, reversal, payoff, or other thread beat." },
73
- { tool: "connect_character_place_evidence", note: "Use when a scene proves a character/place association; SQLite scene relationship indexes commit first and sidecar characters/places remain generated compatibility output." },
73
+ { tool: "connect_character_place_evidence", note: "Use when a scene proves paired sheet-backed character/place evidence; SQLite scene relationship indexes commit first and sidecar characters/places remain generated compatibility output. Independent character-only or place-only scene links remain valid, but need a deliberately named future workflow for daily repair." },
74
74
  { tool: "record_character_relationship_beat", note: "Use when a scene proves a relationship beat between two characters without exposing the character_relationships table." },
75
75
  { tool: "link_reference_evidence", note: "Use when scene, character, place, or reference evidence should point to a reference document; SQLite commits first and compatibility output is generated transparency." },
76
- { tool: "audit_relationship_metadata", note: "Use before repair work to review stale relationship indexes and sidecar-only compatibility notes without mutating canonical state." },
76
+ { tool: "audit_relationship_metadata", note: "Use before repair work to review stale relationship indexes, retained sidecar/frontmatter characters or places, and compatibility drift without mutating canonical state." },
77
77
  { tool: "suggest_scene_references", note: "Use preview first to review candidate scene-reference links from character/place evidence; use apply only after the relationship outcome is intended." },
78
78
  { tool: "enrich_scene_characters_batch", note: "Use dry_run first when prose-derived character relationship parity needs repair across multiple scenes; apply mode syncs SQLite after compatibility output and refreshes backups for changed scenes." },
79
79
  { tool: "diagnose_project_backups", note: "Relationship mutation tools refresh project backups after canonical commits; run this if backup_warnings were returned or recovery readiness matters." },
@@ -101,9 +101,9 @@ export const WORKFLOW_CATALOGUE = [
101
101
  steps: [
102
102
  { tool: "sync", note: "Refresh SQLite indexes from current compatibility inputs, then review warnings instead of patching sidecars as the first repair step." },
103
103
  { tool: "diagnose_structure", note: "Use when sidecar, frontmatter, folder, chapter, epigraph, or generated-export structure appears to disagree with SQLite canonical state." },
104
- { tool: "audit_relationship_metadata", note: "Use when sidecar threads, tags, flags, associated_characters, characters, places, or reference aliases need authority classification before repair." },
104
+ { tool: "audit_relationship_metadata", note: "Use when sidecar threads, tags, flags, associated_characters, characters, places, or reference aliases need authority classification before repair; relationship fields are compatibility input/review evidence, not generic metadata writes." },
105
105
  { tool: "track_thread_arc", note: "Use for current thread authority instead of editing sidecar threads." },
106
- { tool: "connect_character_place_evidence", note: "Use for current scene-backed character/place authority instead of editing sidecar relationship lists." },
106
+ { tool: "connect_character_place_evidence", note: "Use for paired scene-backed character/place evidence instead of editing sidecar relationship lists or sending characters/places through update_scene_metadata; independent one-sided links remain valid but are outside this generic metadata path." },
107
107
  { tool: "link_reference_evidence", note: "Use for current reference-link authority instead of editing sidecar/frontmatter aliases." },
108
108
  { tool: "export_project_backup", note: "Generate a recovery snapshot from SQLite canonical state after meaningful canonical migration or repair work; editing backup artifacts does not mutate current state." },
109
109
  ],