@hanna84/mcp-writing 3.9.3 → 3.9.5

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.9.5](https://github.com/hannasdev/mcp-writing/compare/v3.9.4...v3.9.5)
8
+
9
+ - refactor(sync): complete target architecture M3 [`#201`](https://github.com/hannasdev/mcp-writing/pull/201)
10
+
11
+ #### [v3.9.4](https://github.com/hannasdev/mcp-writing/compare/v3.9.3...v3.9.4)
12
+
13
+ > 17 May 2026
14
+
15
+ - refactor(structure): centralize scene structure patches [`#200`](https://github.com/hannasdev/mcp-writing/pull/200)
16
+ - Release 3.9.4 [`ac77e80`](https://github.com/hannasdev/mcp-writing/commit/ac77e801d94ca576923a46a6d22c8f3230988d76)
17
+
7
18
  #### [v3.9.3](https://github.com/hannasdev/mcp-writing/compare/v3.9.2...v3.9.3)
8
19
 
20
+ > 17 May 2026
21
+
9
22
  - refactor(structure): extract target architecture M1 boundaries [`#199`](https://github.com/hannasdev/mcp-writing/pull/199)
23
+ - Release 3.9.3 [`55c87ea`](https://github.com/hannasdev/mcp-writing/commit/55c87ea405841153765144d32c57803b6139a103)
10
24
 
11
25
  #### [v3.9.2](https://github.com/hannasdev/mcp-writing/compare/v3.9.1...v3.9.2)
12
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanna84/mcp-writing",
3
- "version": "3.9.3",
3
+ "version": "3.9.5",
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",
@@ -109,24 +109,35 @@ export function inferChapterStructureFromPath(syncDir, filePath, meta = {}) {
109
109
  };
110
110
  }
111
111
 
112
- export function normalizeSceneMetaForPath(syncDir, filePath, meta = {}) {
112
+ export function buildSceneStructurePatch(syncDir, filePath, meta = {}, { chapter } = {}) {
113
113
  const derived = inferScenePositionFromPath(syncDir, filePath);
114
114
  const chapterStructure = inferChapterStructureFromPath(syncDir, filePath, meta);
115
- const normalized = { ...meta };
115
+ const patch = {};
116
116
 
117
- if (derived.part !== null) normalized.part = derived.part;
118
- if (derived.chapter !== null) normalized.chapter = derived.chapter;
117
+ if (derived.part !== null) patch.part = derived.part;
118
+ if (derived.chapter !== null) patch.chapter = derived.chapter;
119
119
  if (chapterStructure.chapter?.chapter_id) {
120
- normalized.chapter_id = chapterStructure.chapter.chapter_id;
121
- normalized.chapter = chapterStructure.chapter.sort_index;
122
- normalized.chapter_title = chapterStructure.chapter.title;
120
+ patch.chapter_id = chapterStructure.chapter.chapter_id;
121
+ patch.chapter = chapterStructure.chapter.sort_index;
122
+ patch.chapter_title = chapterStructure.chapter.title;
123
123
  }
124
124
  if (chapterStructure.role) {
125
- normalized.scene_role = chapterStructure.role;
125
+ patch.scene_role = chapterStructure.role;
126
+ }
127
+ if (chapter !== undefined) {
128
+ if (chapter === null) {
129
+ patch.chapter_id = null;
130
+ patch.chapter = null;
131
+ patch.chapter_title = null;
132
+ } else {
133
+ patch.chapter_id = chapter.chapter_id;
134
+ patch.chapter = chapter.sort_index;
135
+ patch.chapter_title = chapter.title ?? null;
136
+ }
126
137
  }
127
138
 
128
139
  return {
129
- meta: normalized,
140
+ patch,
130
141
  derived,
131
142
  chapterStructure,
132
143
  mismatches: {
@@ -135,3 +146,15 @@ export function normalizeSceneMetaForPath(syncDir, filePath, meta = {}) {
135
146
  },
136
147
  };
137
148
  }
149
+
150
+ export function applySceneStructurePatch(syncDir, filePath, meta = {}, options = {}) {
151
+ const plan = buildSceneStructurePatch(syncDir, filePath, meta, options);
152
+ return {
153
+ ...plan,
154
+ meta: { ...meta, ...plan.patch },
155
+ };
156
+ }
157
+
158
+ export function normalizeSceneMetaForPath(syncDir, filePath, meta = {}) {
159
+ return applySceneStructurePatch(syncDir, filePath, meta);
160
+ }
package/src/sync/sync.js CHANGED
@@ -3,6 +3,8 @@ import path from "node:path";
3
3
  import matter from "gray-matter";
4
4
  import yaml from "js-yaml";
5
5
  import {
6
+ applySceneStructurePatch,
7
+ buildSceneStructurePatch,
6
8
  inferChapterStructureFromPath,
7
9
  inferScenePositionFromPath,
8
10
  normalizeSceneMetaForPath,
@@ -16,6 +18,8 @@ import { indexCanonicalEpigraph } from "../structure/epigraph-indexing.js";
16
18
  const { load: parseYaml, dump: stringifyYaml } = yaml;
17
19
 
18
20
  export {
21
+ applySceneStructurePatch,
22
+ buildSceneStructurePatch,
19
23
  inferChapterStructureFromPath,
20
24
  inferScenePositionFromPath,
21
25
  normalizeSceneMetaForPath,
@@ -70,11 +74,34 @@ export function walkSidecars(dir, fileList = []) {
70
74
  return fileList;
71
75
  }
72
76
 
77
+ export function buildSyncDiagnostic(message, { type = null, ...details } = {}) {
78
+ return { type, message, ...details };
79
+ }
80
+
73
81
  function isNestedMirrorPath(syncDir, filePath) {
74
82
  const rel = path.relative(syncDir, filePath).split(path.sep).join("/");
75
83
  return rel.includes("/scenes/projects/") || rel.includes("/scenes/universes/");
76
84
  }
77
85
 
86
+ export function scanSyncFiles(syncDir) {
87
+ const files = [];
88
+ const diagnostics = [];
89
+
90
+ for (const file of walkFiles(syncDir)) {
91
+ if (isNestedMirrorPath(syncDir, file)) {
92
+ const relativePath = path.relative(syncDir, file);
93
+ diagnostics.push(buildSyncDiagnostic(`Ignored nested mirror path: ${relativePath}`, {
94
+ type: "nested_mirror",
95
+ relativePath,
96
+ }));
97
+ continue;
98
+ }
99
+ files.push(file);
100
+ }
101
+
102
+ return { files, diagnostics };
103
+ }
104
+
78
105
  export function sidecarPath(filePath) {
79
106
  return filePath.replace(/\.(md|txt)$/, ".meta.yaml");
80
107
  }
@@ -809,8 +836,14 @@ export function indexReferenceFile(db, syncDir, file, meta = {}, content = "") {
809
836
  return docId;
810
837
  }
811
838
 
812
- function pruneMissingReferenceDocs(db, seenDocIds) {
813
- const rows = db.prepare(`SELECT doc_id, project_id FROM reference_docs`).all();
839
+ function pruneMissingReferenceDocs(db, seenDocIds, syncDir) {
840
+ const scope = inferReferenceScopeFromSyncDir(syncDir);
841
+ const rows = scope?.project_id
842
+ ? db.prepare(`SELECT doc_id, project_id FROM reference_docs WHERE project_id = ?`).all(scope.project_id)
843
+ : scope?.universe_id
844
+ ? db.prepare(`SELECT doc_id, project_id FROM reference_docs WHERE universe_id = ?`).all(scope.universe_id)
845
+ : db.prepare(`SELECT doc_id, project_id FROM reference_docs`).all();
846
+
814
847
  for (const row of rows) {
815
848
  if (seenDocIds.has(row.doc_id)) continue;
816
849
  db.prepare(`
@@ -942,9 +975,157 @@ function pruneMissingEpigraphs(db, seenEpigraphKeys, syncDir) {
942
975
  }
943
976
  }
944
977
 
945
- export function indexSceneFile(db, syncDir, file, meta, prose) {
978
+ export function pruneSyncDerivedIndexes(db, syncDir, {
979
+ seenSceneKeys,
980
+ seenEpigraphKeys,
981
+ seenChapterKeys,
982
+ sceneIndexFailures,
983
+ }) {
984
+ if (!canPruneScenes(syncDir)) return { pruned: false, reason: "scope_not_prunable" };
985
+ if (sceneIndexFailures !== 0) return { pruned: false, reason: "scene_index_failures" };
986
+
987
+ pruneMissingScenes(db, seenSceneKeys, syncDir);
988
+ pruneMissingEpigraphs(db, seenEpigraphKeys, syncDir);
989
+ pruneMissingChapters(db, seenChapterKeys, syncDir);
990
+ return { pruned: true, reason: null };
991
+ }
992
+
993
+ export function regenerateReferenceAndWorldIndexes(db, syncDir, files, {
994
+ writable = false,
995
+ pruneReferenceDocs = false,
996
+ } = {}) {
997
+ const indexedReferenceDocIds = new Set();
998
+
999
+ for (const file of files) {
1000
+ if (isReferenceFile(syncDir, file)) {
1001
+ try {
1002
+ const { data, content } = parseFile(file);
1003
+ const docId = indexReferenceFile(db, syncDir, file, data, content);
1004
+ indexedReferenceDocIds.add(docId);
1005
+ } catch (err) {
1006
+ process.stderr.write(`[mcp-writing] Failed to index ${file}: ${err.message}\n`);
1007
+ }
1008
+ continue;
1009
+ }
1010
+
1011
+ if (!isWorldFile(syncDir, file)) continue;
1012
+ try {
1013
+ const { meta } = readMeta(file, syncDir, { writable });
1014
+ if (!Object.keys(meta).length) {
1015
+ const { data } = parseFile(file);
1016
+ indexWorldFile(db, syncDir, file, data);
1017
+ } else {
1018
+ indexWorldFile(db, syncDir, file, meta);
1019
+ }
1020
+ } catch (err) {
1021
+ process.stderr.write(`[mcp-writing] Failed to index ${file}: ${err.message}\n`);
1022
+ }
1023
+ }
1024
+
1025
+ if (pruneReferenceDocs && canPruneReferenceDocs(syncDir)) {
1026
+ pruneMissingReferenceDocs(db, indexedReferenceDocIds, syncDir);
1027
+ }
1028
+
1029
+ return {
1030
+ indexedReferenceDocIds,
1031
+ };
1032
+ }
1033
+
1034
+ export function buildStructureDiagnostic(message, { type = "chapter_structure", ...details } = {}) {
1035
+ return { type, message, ...details };
1036
+ }
1037
+
1038
+ export function observeStructureForFile(syncDir, file, {
1039
+ meta = {},
1040
+ sourceMeta = {},
1041
+ derived = {},
1042
+ mismatches = {},
1043
+ } = {}) {
946
1044
  const { universe_id, project_id } = inferProjectAndUniverse(syncDir, file);
1045
+ const relativePath = path.relative(syncDir, file);
947
1046
  const chapterStructure = inferChapterStructureFromPath(syncDir, file, meta);
1047
+ const diagnostics = [];
1048
+
1049
+ if (mismatches.part || mismatches.chapter) {
1050
+ const details = [];
1051
+ if (mismatches.part) details.push(`part metadata ${sourceMeta.part} != path part ${derived.part}`);
1052
+ if (mismatches.chapter) details.push(`chapter metadata ${sourceMeta.chapter} != path chapter ${derived.chapter}`);
1053
+ diagnostics.push(buildStructureDiagnostic(
1054
+ `Path/metadata mismatch for scene "${meta.scene_id}": ${relativePath} (${details.join(", ")}). Using path-derived values.`,
1055
+ {
1056
+ type: "path_metadata_mismatch",
1057
+ sceneId: meta.scene_id ?? null,
1058
+ relativePath,
1059
+ }
1060
+ ));
1061
+ }
1062
+
1063
+ return {
1064
+ universeId: universe_id,
1065
+ projectId: project_id,
1066
+ relativePath,
1067
+ chapterStructure,
1068
+ observedChapter: chapterStructure.chapter
1069
+ ? {
1070
+ chapterId: chapterStructure.chapter.chapter_id,
1071
+ sortIndex: chapterStructure.chapter.sort_index,
1072
+ title: chapterStructure.chapter.title,
1073
+ folderKey: chapterStructure.chapter.folder_key,
1074
+ sourceKind: chapterStructure.chapter.source_kind,
1075
+ }
1076
+ : null,
1077
+ observedEpigraph: chapterStructure.isEpigraph
1078
+ ? {
1079
+ epigraphId: meta.epigraph_id ?? null,
1080
+ chapterId: meta.chapter_id ?? chapterStructure.chapter?.chapter_id ?? null,
1081
+ relativePath,
1082
+ }
1083
+ : null,
1084
+ diagnostics,
1085
+ };
1086
+ }
1087
+
1088
+ export function buildCanonicalIndexPlan(db, syncDir, file, meta, observedStructure = observeStructureForFile(syncDir, file, { meta })) {
1089
+ const chapterResolution = resolveIndexedChapterForFile(db, {
1090
+ syncDir,
1091
+ projectId: observedStructure.projectId,
1092
+ filePath: file,
1093
+ relativePath: observedStructure.relativePath,
1094
+ meta,
1095
+ chapterStructure: observedStructure.chapterStructure,
1096
+ });
1097
+
1098
+ return {
1099
+ universeId: observedStructure.universeId,
1100
+ projectId: observedStructure.projectId,
1101
+ observedStructure,
1102
+ chapterResolution,
1103
+ canonicalChapter: chapterResolution.upsertChapter,
1104
+ diagnostics: chapterResolution.chapterWarning
1105
+ ? [buildStructureDiagnostic(chapterResolution.chapterWarning)]
1106
+ : [],
1107
+ };
1108
+ }
1109
+
1110
+ export function readSceneMetadataForSync(syncDir, file, { writable = false } = {}) {
1111
+ return readMeta(file, syncDir, { writable });
1112
+ }
1113
+
1114
+ export function readSceneFileForSync(syncDir, file, { writable = false } = {}) {
1115
+ const metadataRead = readSceneMetadataForSync(syncDir, file, { writable });
1116
+ const { data: frontmatter, content: prose } = parseFile(file);
1117
+
1118
+ return {
1119
+ ...metadataRead,
1120
+ frontmatter,
1121
+ prose,
1122
+ };
1123
+ }
1124
+
1125
+ export function indexSceneFile(db, syncDir, file, meta, prose, { observedStructure } = {}) {
1126
+ const canonicalIndexPlan = buildCanonicalIndexPlan(db, syncDir, file, meta, observedStructure);
1127
+ const { universeId: universe_id, projectId: project_id } = canonicalIndexPlan;
1128
+ const { chapterStructure } = canonicalIndexPlan.observedStructure;
948
1129
  const referenceIds = normalizeReferenceIdList(meta.reference_ids ?? meta.references);
949
1130
  const explicitSceneLinks = collectExplicitReferenceLinks(
950
1131
  meta,
@@ -961,22 +1142,13 @@ export function indexSceneFile(db, syncDir, file, meta, prose) {
961
1142
  project_id, universe_id ?? null, project_id
962
1143
  );
963
1144
 
964
- const relativePath = path.relative(syncDir, file);
965
- const chapterResolution = resolveIndexedChapterForFile(db, {
966
- syncDir,
967
- projectId: project_id,
968
- filePath: file,
969
- relativePath,
970
- meta,
971
- chapterStructure,
972
- });
973
1145
  const {
974
1146
  chapterId,
975
1147
  chapterSortIndex,
976
1148
  chapterTitle,
977
1149
  chapterWarning,
978
1150
  upsertChapter,
979
- } = chapterResolution;
1151
+ } = canonicalIndexPlan.chapterResolution;
980
1152
 
981
1153
  if (upsertChapter) {
982
1154
  upsertCanonicalChapterRecord(db, {
@@ -987,7 +1159,7 @@ export function indexSceneFile(db, syncDir, file, meta, prose) {
987
1159
  }
988
1160
 
989
1161
  if (chapterStructure.isEpigraph) {
990
- return indexCanonicalEpigraph(db, {
1162
+ const result = indexCanonicalEpigraph(db, {
991
1163
  projectId: project_id,
992
1164
  chapterId,
993
1165
  chapterSortIndex,
@@ -995,11 +1167,12 @@ export function indexSceneFile(db, syncDir, file, meta, prose) {
995
1167
  meta,
996
1168
  prose,
997
1169
  file,
998
- relativePath,
1170
+ relativePath: canonicalIndexPlan.observedStructure.relativePath,
999
1171
  chapterWarning,
1000
1172
  buildProseChecksum: checksumProse,
1001
1173
  buildDefaultEpigraphId: ({ projectId, chapterId }) => `epi-${slugifyChapterValue(`${projectId}-${chapterId}`)}`,
1002
1174
  });
1175
+ return { ...result, canonicalIndexPlan };
1003
1176
  }
1004
1177
 
1005
1178
  const newChecksum = checksumProse(prose);
@@ -1137,7 +1310,48 @@ export function indexSceneFile(db, syncDir, file, meta, prose) {
1137
1310
  relation: "informs",
1138
1311
  });
1139
1312
 
1140
- return { isStale, chapterId, warning: chapterWarning };
1313
+ return { isStale, chapterId, warning: chapterWarning, canonicalIndexPlan };
1314
+ }
1315
+
1316
+ export function observeOrphanedSidecars(syncDir, { indexedSceneIds = new Set() } = {}) {
1317
+ const diagnostics = [];
1318
+ const sidecars = walkSidecars(syncDir).filter(sidecar => !isNestedMirrorPath(syncDir, sidecar));
1319
+
1320
+ for (const sidecar of sidecars) {
1321
+ const prose = sidecar.replace(/\.meta\.yaml$/, ".md");
1322
+ const proseTxt = sidecar.replace(/\.meta\.yaml$/, ".txt");
1323
+ if (fs.existsSync(prose) || fs.existsSync(proseTxt)) continue;
1324
+
1325
+ let orphanedSceneId = null;
1326
+ try {
1327
+ const raw = fs.readFileSync(sidecar, "utf8");
1328
+ orphanedSceneId = (parseYaml(raw) ?? {}).scene_id ?? null;
1329
+ } catch { /* empty */ }
1330
+
1331
+ const relativePath = path.relative(syncDir, sidecar);
1332
+ if (orphanedSceneId && indexedSceneIds.has(orphanedSceneId)) {
1333
+ diagnostics.push(buildSyncDiagnostic(
1334
+ `Moved scene detected: sidecar for "${orphanedSceneId}" is at stale path ${relativePath} — prose file has moved. Consider relocating the sidecar alongside the prose file.`,
1335
+ {
1336
+ type: "moved_scene",
1337
+ sceneId: orphanedSceneId,
1338
+ relativePath,
1339
+ }
1340
+ ));
1341
+ } else {
1342
+ const label = orphanedSceneId ? `scene "${orphanedSceneId}"` : "unknown scene";
1343
+ diagnostics.push(buildSyncDiagnostic(
1344
+ `Orphaned sidecar (${label}, no matching .md/.txt and not indexed): ${relativePath}`,
1345
+ {
1346
+ type: "orphaned_sidecar",
1347
+ sceneId: orphanedSceneId,
1348
+ relativePath,
1349
+ }
1350
+ ));
1351
+ }
1352
+ }
1353
+
1354
+ return diagnostics;
1141
1355
  }
1142
1356
 
1143
1357
  const WARNING_TYPE_LABELS = {
@@ -1162,7 +1376,7 @@ const WARNING_PATTERNS = [
1162
1376
 
1163
1377
  const MAX_WARNING_EXAMPLES = 5;
1164
1378
 
1165
- function buildWarningSummary(warnings) {
1379
+ export function buildWarningSummary(warnings) {
1166
1380
  const summary = {};
1167
1381
  for (const w of warnings) {
1168
1382
  const firstLine = w.split("\n")[0];
@@ -1182,7 +1396,6 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
1182
1396
  // (for example after imports or path repairs) are reflected immediately.
1183
1397
  UNIVERSE_PROJECT_ROOT_CACHE.clear();
1184
1398
 
1185
- const files = walkFiles(syncDir);
1186
1399
  let indexed = 0;
1187
1400
  let staleMarked = 0;
1188
1401
  let epigraphsIndexed = 0;
@@ -1194,69 +1407,43 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
1194
1407
  const indexedSceneIds = new Set(); // scene_id only — for orphaned sidecar move detection
1195
1408
  const seenChapterKeys = new Set();
1196
1409
  const seenEpigraphKeys = new Set();
1197
- const indexedReferenceDocIds = new Set();
1198
1410
  let sceneIndexFailures = 0;
1199
1411
  const warnings = [];
1200
1412
  const chapterFoldersByProject = new Map();
1201
1413
  const roleFoldersByProject = new Map();
1202
1414
 
1203
- const scanFiles = [];
1204
- for (const file of files) {
1205
- if (isNestedMirrorPath(syncDir, file)) {
1206
- warnings.push(`Ignored nested mirror path: ${path.relative(syncDir, file)}`);
1207
- continue;
1208
- }
1209
- scanFiles.push(file);
1415
+ const syncScan = scanSyncFiles(syncDir);
1416
+ const scanFiles = syncScan.files;
1417
+ for (const diagnostic of syncScan.diagnostics) {
1418
+ warnings.push(diagnostic.message);
1210
1419
  }
1211
1420
 
1212
1421
  // --- Pass 1: world files and reference docs (characters/places must be indexed
1213
1422
  // before scenes so that character name -> ID resolution in scene_characters works) ---
1214
- for (const file of scanFiles) {
1215
- if (isReferenceFile(syncDir, file)) {
1216
- try {
1217
- const { data, content } = parseFile(file);
1218
- const docId = indexReferenceFile(db, syncDir, file, data, content);
1219
- indexedReferenceDocIds.add(docId);
1220
- } catch (err) {
1221
- process.stderr.write(`[mcp-writing] Failed to index ${file}: ${err.message}\n`);
1222
- }
1223
- continue;
1224
- }
1225
-
1226
- if (!isWorldFile(syncDir, file)) continue;
1227
- try {
1228
- const { meta } = readMeta(file, syncDir, { writable });
1229
- if (!Object.keys(meta).length) {
1230
- const { data } = parseFile(file);
1231
- indexWorldFile(db, syncDir, file, data);
1232
- } else {
1233
- indexWorldFile(db, syncDir, file, meta);
1234
- }
1235
- } catch (err) {
1236
- process.stderr.write(`[mcp-writing] Failed to index ${file}: ${err.message}\n`);
1237
- }
1238
- }
1239
-
1240
- if (canPruneReferenceDocs(syncDir)) {
1241
- pruneMissingReferenceDocs(db, indexedReferenceDocIds);
1242
- }
1423
+ regenerateReferenceAndWorldIndexes(db, syncDir, scanFiles, { writable, pruneReferenceDocs: true });
1243
1424
 
1244
1425
  // --- Pass 2: scene files ---
1245
1426
  for (const file of scanFiles) {
1246
1427
  if (isWorldFile(syncDir, file) || isReferenceFile(syncDir, file)) continue;
1247
1428
  try {
1248
- const { meta, sourceMeta, sidecarGenerated, derived, mismatches } = readMeta(file, syncDir, { writable });
1429
+ const { meta, sourceMeta, sidecarGenerated, derived, mismatches } = readSceneMetadataForSync(syncDir, file, { writable });
1249
1430
  if (sidecarGenerated) sidecarsMigrated++;
1250
- const chapterStructure = inferChapterStructureFromPath(syncDir, file, meta);
1431
+ const structureObservation = observeStructureForFile(syncDir, file, {
1432
+ meta,
1433
+ sourceMeta,
1434
+ derived,
1435
+ mismatches,
1436
+ });
1437
+ const { chapterStructure } = structureObservation;
1251
1438
 
1252
1439
  if (!meta.scene_id && !chapterStructure.isEpigraph) {
1253
1440
  skipped++;
1254
- if (!quiet) warnings.push(`Skipped (no scene_id): ${path.relative(syncDir, file)}`);
1441
+ if (!quiet) warnings.push(`Skipped (no scene_id): ${structureObservation.relativePath}`);
1255
1442
  continue;
1256
1443
  }
1257
1444
 
1258
1445
  // Duplicate scene_id detection
1259
- const { project_id } = inferProjectAndUniverse(syncDir, file);
1446
+ const project_id = structureObservation.projectId;
1260
1447
  const key = `${meta.scene_id}::${project_id}`;
1261
1448
  if (meta.scene_id && seenSceneIds.has(key)) {
1262
1449
  warnings.push(
@@ -1280,18 +1467,17 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
1280
1467
  }
1281
1468
  }
1282
1469
 
1283
- if (mismatches.part || mismatches.chapter) {
1284
- const details = [];
1285
- if (mismatches.part) details.push(`part metadata ${sourceMeta.part} != path part ${derived.part}`);
1286
- if (mismatches.chapter) details.push(`chapter metadata ${sourceMeta.chapter} != path chapter ${derived.chapter}`);
1287
- warnings.push(
1288
- `Path/metadata mismatch for scene "${meta.scene_id}": ${path.relative(syncDir, file)} (${details.join(", ")}). Using path-derived values.`
1289
- );
1470
+ for (const diagnostic of structureObservation.diagnostics) {
1471
+ warnings.push(diagnostic.message);
1290
1472
  }
1291
1473
 
1292
- const { data: _frontmatter, content: prose } = parseFile(file);
1293
- const result = indexSceneFile(db, syncDir, file, meta, prose);
1294
- if (result.warning) {
1474
+ const { content: prose } = parseFile(file);
1475
+
1476
+ const result = indexSceneFile(db, syncDir, file, meta, prose, { observedStructure: structureObservation });
1477
+ const canonicalDiagnostics = result.canonicalIndexPlan?.diagnostics ?? [];
1478
+ if (canonicalDiagnostics.length) {
1479
+ for (const diagnostic of canonicalDiagnostics) warnings.push(diagnostic.message);
1480
+ } else if (result.warning) {
1295
1481
  warnings.push(result.warning);
1296
1482
  }
1297
1483
  if (result.chapterId) {
@@ -1333,35 +1519,16 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
1333
1519
  }
1334
1520
  }
1335
1521
 
1336
- if (canPruneScenes(syncDir) && sceneIndexFailures === 0) {
1337
- pruneMissingScenes(db, seenSceneKeys, syncDir);
1338
- pruneMissingEpigraphs(db, seenEpigraphKeys, syncDir);
1339
- pruneMissingChapters(db, seenChapterKeys, syncDir);
1340
- }
1522
+ pruneSyncDerivedIndexes(db, syncDir, {
1523
+ seenSceneKeys,
1524
+ seenEpigraphKeys,
1525
+ seenChapterKeys,
1526
+ sceneIndexFailures,
1527
+ });
1341
1528
 
1342
1529
  // --- Orphaned sidecar detection ---
1343
- const sidecars = walkSidecars(syncDir).filter(sidecar => !isNestedMirrorPath(syncDir, sidecar));
1344
- for (const sidecar of sidecars) {
1345
- const prose = sidecar.replace(/\.meta\.yaml$/, ".md");
1346
- const proseTxt = sidecar.replace(/\.meta\.yaml$/, ".txt");
1347
- if (!fs.existsSync(prose) && !fs.existsSync(proseTxt)) {
1348
- let orphanedSceneId = null;
1349
- try {
1350
- const raw = fs.readFileSync(sidecar, "utf8");
1351
- orphanedSceneId = (parseYaml(raw) ?? {}).scene_id ?? null;
1352
- } catch { /* empty */ }
1353
-
1354
- if (orphanedSceneId && indexedSceneIds.has(orphanedSceneId)) {
1355
- warnings.push(
1356
- `Moved scene detected: sidecar for "${orphanedSceneId}" is at stale path ${path.relative(syncDir, sidecar)} — prose file has moved. Consider relocating the sidecar alongside the prose file.`
1357
- );
1358
- } else {
1359
- const label = orphanedSceneId ? `scene "${orphanedSceneId}"` : "unknown scene";
1360
- warnings.push(
1361
- `Orphaned sidecar (${label}, no matching .md/.txt and not indexed): ${path.relative(syncDir, sidecar)}`
1362
- );
1363
- }
1364
- }
1530
+ for (const diagnostic of observeOrphanedSidecars(syncDir, { indexedSceneIds })) {
1531
+ warnings.push(diagnostic.message);
1365
1532
  }
1366
1533
 
1367
1534
  const warningSummary = buildWarningSummary(warnings);
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import fs from "node:fs";
3
3
  import matter from "gray-matter";
4
- import { readMeta, writeMeta, indexSceneFile, normalizeSceneMetaForPath } from "../sync/sync.js";
4
+ import { readMeta, writeMeta, indexSceneFile, applySceneStructurePatch } from "../sync/sync.js";
5
5
  import { validateProjectId, validateUniverseId } from "../sync/importer.js";
6
6
  import { resolveValidatedChapterFilter } from "../core/chapter-resolution.js";
7
7
  import {
@@ -545,6 +545,7 @@ export function registerMetadataTools(s, {
545
545
  try {
546
546
  const { meta } = readMeta(scene.file_path, SYNC_DIR, { writable: true });
547
547
  const nextFields = { ...fields };
548
+ let chapter = undefined;
548
549
 
549
550
  if (fields.chapter_id === null && fields.chapter !== undefined) {
550
551
  return errorResponse(
@@ -559,8 +560,20 @@ export function registerMetadataTools(s, {
559
560
  }
560
561
 
561
562
  if (fields.chapter_id === null) {
562
- nextFields.chapter = null;
563
- nextFields.chapter_title = null;
563
+ const structurePlan = applySceneStructurePatch(SYNC_DIR, scene.file_path, meta);
564
+ if (structurePlan.derived.chapter !== null || structurePlan.chapterStructure.chapter?.chapter_id) {
565
+ return errorResponse(
566
+ "VALIDATION_ERROR",
567
+ "chapter_id cannot be cleared for a scene whose file path implies a chapter.",
568
+ {
569
+ project_id,
570
+ scene_id,
571
+ chapter_id: null,
572
+ path_chapter: structurePlan.chapterStructure.chapter?.chapter_id ?? structurePlan.derived.chapter,
573
+ }
574
+ );
575
+ }
576
+ chapter = null;
564
577
  } else if (fields.chapter_id !== undefined || fields.chapter !== undefined) {
565
578
  const resolvedChapterFilter = resolveValidatedChapterFilter(db, {
566
579
  projectId: project_id,
@@ -594,12 +607,10 @@ export function registerMetadataTools(s, {
594
607
  );
595
608
  }
596
609
 
597
- nextFields.chapter_id = resolvedChapter.chapter_id;
598
- nextFields.chapter = resolvedChapter.sort_index;
599
- nextFields.chapter_title = resolvedChapter.title ?? null;
610
+ chapter = resolvedChapter;
600
611
  }
601
612
 
602
- const updated = normalizeSceneMetaForPath(SYNC_DIR, scene.file_path, { ...meta, ...nextFields }).meta;
613
+ const updated = applySceneStructurePatch(SYNC_DIR, scene.file_path, { ...meta, ...nextFields }, { chapter }).meta;
603
614
  writeMeta(scene.file_path, updated);
604
615
 
605
616
  const { content: prose } = matter(fs.readFileSync(scene.file_path, "utf8"));