@hanna84/mcp-writing 3.7.1 → 3.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/sync/sync.js CHANGED
@@ -79,16 +79,118 @@ export function inferScenePositionFromPath(syncDir, filePath) {
79
79
  return { part, chapter };
80
80
  }
81
81
 
82
+ function titleCaseFolderLabel(value) {
83
+ return String(value ?? "")
84
+ .replace(/[-_]+/g, " ")
85
+ .replace(/\s+/g, " ")
86
+ .trim()
87
+ .replace(/\b\w/g, char => char.toUpperCase());
88
+ }
89
+
90
+ function slugifyChapterValue(value) {
91
+ return String(value ?? "")
92
+ .toLowerCase()
93
+ .replace(/[^a-z0-9]+/g, "-")
94
+ .replace(/^-+|-+$/g, "");
95
+ }
96
+
97
+ function isExplicitChapterContainer(parts, index) {
98
+ const parent = parts[index - 1]?.toLowerCase() ?? null;
99
+ return parent === "draft" || parent === "scenes";
100
+ }
101
+
102
+ export function inferChapterStructureFromPath(syncDir, filePath, meta = {}) {
103
+ const rel = path.relative(syncDir, filePath);
104
+ const parts = rel.split(path.sep);
105
+ let role = null;
106
+ let chapterFolder = null;
107
+ let chapterSortIndex = null;
108
+ let chapterTitle = null;
109
+ let chapterFolderKey = null;
110
+
111
+ for (let index = 0; index < parts.length - 1; index += 1) {
112
+ const segment = parts[index];
113
+ const normalized = segment.toLowerCase();
114
+
115
+ if (normalized === "prologue" || normalized === "00-prologue") {
116
+ role = "prologue";
117
+ continue;
118
+ }
119
+ if (normalized === "epilogue" || normalized === "99-epilogue") {
120
+ role = "epilogue";
121
+ continue;
122
+ }
123
+
124
+ let match = segment.match(/^(\d+)-(.+)$/);
125
+ if (!match) {
126
+ match = segment.match(/^chapter-(\d+)(?:-(.+))?$/i);
127
+ }
128
+ if (!match || !isExplicitChapterContainer(parts, index)) continue;
129
+
130
+ chapterFolder = segment;
131
+ chapterSortIndex = Number.parseInt(match[1], 10);
132
+ chapterTitle = titleCaseFolderLabel(match[2] ?? `Chapter ${chapterSortIndex}`);
133
+ chapterFolderKey = parts.slice(0, index + 1).join(path.sep);
134
+ }
135
+
136
+ const baseName = path.basename(filePath, path.extname(filePath)).toLowerCase();
137
+ const explicitEpigraph = meta.kind === "epigraph"
138
+ || meta.type === "epigraph"
139
+ || typeof meta.epigraph_id === "string"
140
+ || baseName === "epigraph";
141
+
142
+ if (chapterSortIndex == null) {
143
+ const fallback = inferScenePositionFromPath(syncDir, filePath);
144
+ if (fallback.chapter != null) {
145
+ chapterSortIndex = fallback.chapter;
146
+ chapterTitle = titleCaseFolderLabel(meta.chapter_title ?? `Chapter ${fallback.chapter}`);
147
+ chapterFolderKey = chapterFolderKey ?? parts.slice(0, Math.max(0, parts.length - 1)).join(path.sep);
148
+ }
149
+ }
150
+
151
+ if (chapterSortIndex == null) {
152
+ return {
153
+ role,
154
+ isEpigraph: explicitEpigraph,
155
+ chapter: null,
156
+ };
157
+ }
158
+
159
+ const chapterSlug = slugifyChapterValue(chapterTitle) || `chapter-${chapterSortIndex}`;
160
+ return {
161
+ role,
162
+ isEpigraph: explicitEpigraph,
163
+ chapter: {
164
+ chapter_id: `ch-${String(chapterSortIndex).padStart(2, "0")}-${chapterSlug}`,
165
+ sort_index: chapterSortIndex,
166
+ title: chapterTitle,
167
+ folder_name: chapterFolder ?? `chapter-${chapterSortIndex}`,
168
+ folder_key: chapterFolderKey ?? parts.slice(0, Math.max(0, parts.length - 1)).join(path.sep),
169
+ source_kind: chapterFolder ? "chapter_folder" : "legacy_layout",
170
+ },
171
+ };
172
+ }
173
+
82
174
  export function normalizeSceneMetaForPath(syncDir, filePath, meta = {}) {
83
175
  const derived = inferScenePositionFromPath(syncDir, filePath);
176
+ const chapterStructure = inferChapterStructureFromPath(syncDir, filePath, meta);
84
177
  const normalized = { ...meta };
85
178
 
86
179
  if (derived.part !== null) normalized.part = derived.part;
87
180
  if (derived.chapter !== null) normalized.chapter = derived.chapter;
181
+ if (chapterStructure.chapter?.chapter_id) {
182
+ normalized.chapter_id = chapterStructure.chapter.chapter_id;
183
+ normalized.chapter = chapterStructure.chapter.sort_index;
184
+ normalized.chapter_title = chapterStructure.chapter.title;
185
+ }
186
+ if (chapterStructure.role) {
187
+ normalized.scene_role = chapterStructure.role;
188
+ }
88
189
 
89
190
  return {
90
191
  meta: normalized,
91
192
  derived,
193
+ chapterStructure,
92
194
  mismatches: {
93
195
  part: derived.part !== null && meta.part != null && meta.part !== derived.part,
94
196
  chapter: derived.chapter !== null && meta.chapter != null && meta.chapter !== derived.chapter,
@@ -926,8 +1028,165 @@ function pruneMissingScenes(db, seenSceneKeys, syncDir) {
926
1028
  }
927
1029
  }
928
1030
 
1031
+ function pruneMissingChapters(db, seenChapterKeys, syncDir) {
1032
+ const projectScope = inferSceneProjectScopeFromSyncDir(syncDir);
1033
+ const rows = projectScope
1034
+ ? db.prepare(`SELECT chapter_id, project_id FROM chapters WHERE project_id = ?`).all(projectScope)
1035
+ : db.prepare(`SELECT chapter_id, project_id FROM chapters`).all();
1036
+
1037
+ for (const row of rows) {
1038
+ const key = `${row.chapter_id}::${row.project_id}`;
1039
+ if (seenChapterKeys.has(key)) continue;
1040
+ db.prepare(`DELETE FROM epigraph_characters WHERE project_id = ? AND epigraph_id IN (SELECT epigraph_id FROM epigraphs WHERE project_id = ? AND chapter_id = ?)`)
1041
+ .run(row.project_id, row.project_id, row.chapter_id);
1042
+ db.prepare(`DELETE FROM epigraph_tags WHERE project_id = ? AND epigraph_id IN (SELECT epigraph_id FROM epigraphs WHERE project_id = ? AND chapter_id = ?)`)
1043
+ .run(row.project_id, row.project_id, row.chapter_id);
1044
+ db.prepare(`DELETE FROM epigraphs WHERE project_id = ? AND chapter_id = ?`).run(row.project_id, row.chapter_id);
1045
+ db.prepare(`DELETE FROM chapters WHERE chapter_id = ? AND project_id = ?`).run(row.chapter_id, row.project_id);
1046
+ }
1047
+ }
1048
+
1049
+ function pruneMissingEpigraphs(db, seenEpigraphKeys, syncDir) {
1050
+ const projectScope = inferSceneProjectScopeFromSyncDir(syncDir);
1051
+ const rows = projectScope
1052
+ ? db.prepare(`SELECT epigraph_id, project_id FROM epigraphs WHERE project_id = ?`).all(projectScope)
1053
+ : db.prepare(`SELECT epigraph_id, project_id FROM epigraphs`).all();
1054
+
1055
+ for (const row of rows) {
1056
+ const key = `${row.epigraph_id}::${row.project_id}`;
1057
+ if (seenEpigraphKeys.has(key)) continue;
1058
+ db.prepare(`DELETE FROM epigraph_characters WHERE epigraph_id = ? AND project_id = ?`).run(row.epigraph_id, row.project_id);
1059
+ db.prepare(`DELETE FROM epigraph_tags WHERE epigraph_id = ? AND project_id = ?`).run(row.epigraph_id, row.project_id);
1060
+ db.prepare(`DELETE FROM epigraphs WHERE epigraph_id = ? AND project_id = ?`).run(row.epigraph_id, row.project_id);
1061
+ }
1062
+ }
1063
+
1064
+ function resolveCanonicalChapterRecord(db, {
1065
+ syncDir,
1066
+ projectId,
1067
+ derivedChapterId,
1068
+ sortIndex,
1069
+ title,
1070
+ sourcePath,
1071
+ allowSourcePathMatch = false,
1072
+ }) {
1073
+ if (!projectId || sortIndex == null || !title) return null;
1074
+
1075
+ const normalizedSourcePath = sourcePath ?? null;
1076
+ const bySourcePath = allowSourcePathMatch && normalizedSourcePath
1077
+ ? db.prepare(`
1078
+ SELECT chapter_id, title, sort_index, logline, source_checksum, metadata_stale
1079
+ FROM chapters
1080
+ WHERE project_id = ? AND source_path = ?
1081
+ `).get(projectId, normalizedSourcePath)
1082
+ : null;
1083
+
1084
+ if (bySourcePath) {
1085
+ return {
1086
+ ...bySourcePath,
1087
+ chapter_id: bySourcePath.chapter_id,
1088
+ title,
1089
+ sort_index: sortIndex,
1090
+ source_path: normalizedSourcePath,
1091
+ };
1092
+ }
1093
+
1094
+ const byTitle = db.prepare(`
1095
+ SELECT chapter_id, title, sort_index, logline, source_path, source_checksum, metadata_stale
1096
+ FROM chapters
1097
+ WHERE project_id = ? AND title = ?
1098
+ ORDER BY chapter_id
1099
+ `).all(projectId, title);
1100
+
1101
+ if (byTitle.length === 1) {
1102
+ const existingTitleSourcePath = byTitle[0].source_path ?? null;
1103
+ const existingTitleSourceExists = Boolean(
1104
+ syncDir
1105
+ && existingTitleSourcePath
1106
+ && fs.existsSync(path.join(syncDir, existingTitleSourcePath))
1107
+ );
1108
+ const canReuseByTitle = allowSourcePathMatch || byTitle[0].sort_index === sortIndex;
1109
+ if (canReuseByTitle && (!existingTitleSourceExists || existingTitleSourcePath === normalizedSourcePath)) {
1110
+ return {
1111
+ ...byTitle[0],
1112
+ chapter_id: byTitle[0].chapter_id,
1113
+ title,
1114
+ sort_index: sortIndex,
1115
+ source_path: normalizedSourcePath,
1116
+ };
1117
+ }
1118
+ }
1119
+
1120
+ if (byTitle.length > 1) {
1121
+ return null;
1122
+ }
1123
+
1124
+ const bySortIndex = db.prepare(`
1125
+ SELECT chapter_id, title, sort_index, logline, source_path, source_checksum, metadata_stale
1126
+ FROM chapters
1127
+ WHERE project_id = ? AND sort_index = ?
1128
+ `).get(projectId, sortIndex);
1129
+
1130
+ if (bySortIndex) {
1131
+ const existingSourceExists = Boolean(
1132
+ syncDir
1133
+ && bySortIndex.source_path
1134
+ && fs.existsSync(path.join(syncDir, bySortIndex.source_path))
1135
+ );
1136
+ if (
1137
+ normalizedSourcePath
1138
+ && bySortIndex.source_path
1139
+ && bySortIndex.source_path !== normalizedSourcePath
1140
+ && existingSourceExists
1141
+ ) {
1142
+ return {
1143
+ ambiguous: true,
1144
+ existingSourcePath: bySortIndex.source_path,
1145
+ conflictingSourcePath: normalizedSourcePath,
1146
+ sort_index: sortIndex,
1147
+ };
1148
+ }
1149
+ return {
1150
+ ...bySortIndex,
1151
+ chapter_id: bySortIndex.chapter_id,
1152
+ title,
1153
+ sort_index: sortIndex,
1154
+ source_path: normalizedSourcePath,
1155
+ };
1156
+ }
1157
+
1158
+ return {
1159
+ chapter_id: derivedChapterId,
1160
+ title,
1161
+ sort_index: sortIndex,
1162
+ source_path: normalizedSourcePath,
1163
+ logline: null,
1164
+ source_checksum: null,
1165
+ metadata_stale: 0,
1166
+ };
1167
+ }
1168
+
1169
+ function parkConflictingChapterSortIndex(db, { projectId, chapterId, targetSortIndex }) {
1170
+ if (!projectId || !chapterId || targetSortIndex == null) return;
1171
+
1172
+ const conflictingChapter = db.prepare(`
1173
+ SELECT chapter_id, sort_index
1174
+ FROM chapters
1175
+ WHERE project_id = ? AND sort_index = ? AND chapter_id != ?
1176
+ `).get(projectId, targetSortIndex, chapterId);
1177
+
1178
+ if (!conflictingChapter) return;
1179
+
1180
+ db.prepare(`
1181
+ UPDATE chapters
1182
+ SET sort_index = ?
1183
+ WHERE project_id = ? AND chapter_id = ?
1184
+ `).run(-1000000 - Number(conflictingChapter.sort_index), projectId, conflictingChapter.chapter_id);
1185
+ }
1186
+
929
1187
  export function indexSceneFile(db, syncDir, file, meta, prose) {
930
1188
  const { universe_id, project_id } = inferProjectAndUniverse(syncDir, file);
1189
+ const chapterStructure = inferChapterStructureFromPath(syncDir, file, meta);
931
1190
  const referenceIds = normalizeReferenceIdList(meta.reference_ids ?? meta.references);
932
1191
  const explicitSceneLinks = collectExplicitReferenceLinks(
933
1192
  meta,
@@ -944,6 +1203,225 @@ export function indexSceneFile(db, syncDir, file, meta, prose) {
944
1203
  project_id, universe_id ?? null, project_id
945
1204
  );
946
1205
 
1206
+ let chapterId = meta.chapter_id ?? chapterStructure.chapter?.chapter_id ?? null;
1207
+ let chapterSortIndex = chapterStructure.chapter?.sort_index ?? meta.chapter ?? null;
1208
+ let chapterTitle = chapterStructure.chapter?.title ?? meta.chapter_title ?? (chapterSortIndex != null ? `Chapter ${chapterSortIndex}` : null);
1209
+ const chapterSourcePath = chapterStructure.chapter?.folder_key ?? path.dirname(file);
1210
+ const allowChapterSourcePathMatch = chapterStructure.chapter?.source_kind === "chapter_folder";
1211
+ let chapterWarning = null;
1212
+ const explicitSceneChapterId = !chapterStructure.isEpigraph ? meta.chapter_id ?? null : null;
1213
+ let explicitSceneCanonicalChapter = null;
1214
+
1215
+ if (explicitSceneChapterId && !chapterStructure.chapter) {
1216
+ explicitSceneCanonicalChapter = db.prepare(`
1217
+ SELECT chapter_id, sort_index, title
1218
+ FROM chapters
1219
+ WHERE chapter_id = ? AND project_id = ?
1220
+ `).get(explicitSceneChapterId, project_id);
1221
+ if (explicitSceneCanonicalChapter) {
1222
+ chapterId = explicitSceneCanonicalChapter.chapter_id;
1223
+ chapterSortIndex = explicitSceneCanonicalChapter.sort_index ?? null;
1224
+ chapterTitle = explicitSceneCanonicalChapter.title ?? null;
1225
+ } else {
1226
+ // Scene-level explicit chapter links must target an existing canonical chapter.
1227
+ chapterSortIndex = null;
1228
+ chapterTitle = null;
1229
+ }
1230
+ }
1231
+ const derivedChapterId = (
1232
+ chapterId
1233
+ ?? (chapterSortIndex != null && chapterTitle
1234
+ ? `ch-${String(chapterSortIndex).padStart(2, "0")}-${slugifyChapterValue(chapterTitle) || `chapter-${chapterSortIndex}`}`
1235
+ : null)
1236
+ );
1237
+
1238
+ if (!explicitSceneCanonicalChapter && chapterSortIndex != null && chapterTitle) {
1239
+ const canonicalChapter = resolveCanonicalChapterRecord(db, {
1240
+ syncDir,
1241
+ projectId: project_id,
1242
+ derivedChapterId,
1243
+ sortIndex: chapterSortIndex,
1244
+ title: chapterTitle,
1245
+ sourcePath: chapterSourcePath,
1246
+ allowSourcePathMatch: allowChapterSourcePathMatch,
1247
+ });
1248
+ if (canonicalChapter?.ambiguous) {
1249
+ chapterWarning = `Chapter structure warning: duplicate chapter order ${chapterSortIndex} in project "${project_id}" for ${canonicalChapter.existingSourcePath} and ${canonicalChapter.conflictingSourcePath}.`;
1250
+ chapterId = null;
1251
+ } else {
1252
+ chapterId = canonicalChapter?.chapter_id ?? chapterId;
1253
+ }
1254
+ if (chapterId) {
1255
+ parkConflictingChapterSortIndex(db, {
1256
+ projectId: project_id,
1257
+ chapterId,
1258
+ targetSortIndex: chapterSortIndex,
1259
+ });
1260
+ const existingChapter = db.prepare(
1261
+ `SELECT logline, source_checksum, metadata_stale FROM chapters WHERE chapter_id = ? AND project_id = ?`
1262
+ ).get(chapterId, project_id);
1263
+ const chapterLogline = meta.chapter_logline ?? existingChapter?.logline ?? null;
1264
+ const chapterChecksum = checksumProse(`${chapterSortIndex}:${chapterTitle}:${chapterLogline ?? ""}`);
1265
+ db.prepare(`
1266
+ INSERT INTO chapters (
1267
+ chapter_id, project_id, title, sort_index, logline, source_path, source_checksum, metadata_stale, updated_at
1268
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
1269
+ ON CONFLICT (chapter_id, project_id) DO UPDATE SET
1270
+ title = excluded.title,
1271
+ sort_index = excluded.sort_index,
1272
+ logline = excluded.logline,
1273
+ source_path = excluded.source_path,
1274
+ source_checksum = excluded.source_checksum,
1275
+ metadata_stale = CASE
1276
+ WHEN excluded.source_checksum != chapters.source_checksum THEN 1
1277
+ ELSE chapters.metadata_stale
1278
+ END,
1279
+ updated_at = excluded.updated_at
1280
+ `).run(
1281
+ chapterId,
1282
+ project_id,
1283
+ chapterTitle,
1284
+ chapterSortIndex,
1285
+ chapterLogline,
1286
+ chapterSourcePath,
1287
+ chapterChecksum,
1288
+ existingChapter && existingChapter.source_checksum !== chapterChecksum ? 1 : 0,
1289
+ new Date().toISOString()
1290
+ );
1291
+ }
1292
+ }
1293
+
1294
+ if (!chapterStructure.isEpigraph && chapterId && (chapterSortIndex == null || !chapterTitle)) {
1295
+ const canonicalChapter = db.prepare(`
1296
+ SELECT chapter_id, sort_index, title
1297
+ FROM chapters
1298
+ WHERE chapter_id = ? AND project_id = ?
1299
+ `).get(chapterId, project_id);
1300
+ if (!canonicalChapter) {
1301
+ chapterWarning = `Scene references unknown chapter_id '${chapterId}': ${path.relative(syncDir, file)}`;
1302
+ chapterId = null;
1303
+ } else {
1304
+ chapterSortIndex = chapterSortIndex ?? canonicalChapter.sort_index ?? null;
1305
+ chapterTitle = chapterTitle ?? canonicalChapter.title ?? null;
1306
+ }
1307
+ }
1308
+
1309
+ if (chapterStructure.isEpigraph) {
1310
+ const canonicalChapter = chapterId
1311
+ ? db.prepare(`SELECT chapter_id FROM chapters WHERE chapter_id = ? AND project_id = ?`).get(chapterId, project_id)
1312
+ : null;
1313
+ if (!chapterId || !canonicalChapter) {
1314
+ const reason = chapterWarning
1315
+ ?? (chapterId
1316
+ ? `Epigraph references unknown chapter_id '${chapterId}': ${path.relative(syncDir, file)}`
1317
+ : null)
1318
+ ?? (chapterStructure.chapter && chapterSortIndex != null
1319
+ ? `Ambiguous chapter linkage from duplicate chapter order ${chapterSortIndex}: ${path.relative(syncDir, file)}`
1320
+ : `Epigraph requires explicit chapter linkage: ${path.relative(syncDir, file)}`);
1321
+ return { isStale: 0, skippedAsEpigraph: true, warning: reason };
1322
+ }
1323
+
1324
+ const defaultEpigraphId = `epi-${slugifyChapterValue(`${project_id}-${chapterId}`)}`;
1325
+ const requestedEpigraphId = meta.epigraph_id ?? defaultEpigraphId;
1326
+ const epigraphChecksum = checksumProse(prose);
1327
+ const epigraphById = db.prepare(`
1328
+ SELECT epigraph_id, chapter_id, prose_checksum
1329
+ FROM epigraphs
1330
+ WHERE epigraph_id = ? AND project_id = ?
1331
+ `).get(requestedEpigraphId, project_id);
1332
+ const epigraphByChapter = db.prepare(`
1333
+ SELECT epigraph_id, chapter_id, prose_checksum
1334
+ FROM epigraphs
1335
+ WHERE chapter_id = ? AND project_id = ?
1336
+ `).get(chapterId, project_id);
1337
+
1338
+ if (
1339
+ epigraphById
1340
+ && epigraphById.chapter_id !== chapterId
1341
+ && (!epigraphByChapter || epigraphByChapter.epigraph_id !== epigraphById.epigraph_id)
1342
+ ) {
1343
+ return {
1344
+ isStale: 0,
1345
+ skippedAsEpigraph: true,
1346
+ warning: `Epigraph identity conflict for chapter '${chapterId}': requested epigraph_id '${requestedEpigraphId}' already belongs to another chapter in project '${project_id}'.`,
1347
+ };
1348
+ }
1349
+
1350
+ const existingEpigraph = epigraphByChapter ?? epigraphById ?? null;
1351
+ const epigraphId = meta.epigraph_id
1352
+ ? requestedEpigraphId
1353
+ : (epigraphByChapter?.epigraph_id ?? requestedEpigraphId);
1354
+ const previousEpigraphId = existingEpigraph?.epigraph_id ?? epigraphId;
1355
+ const existingChecksum = existingEpigraph?.prose_checksum ?? null;
1356
+ const epigraphIsStale = existingChecksum !== null && existingChecksum !== epigraphChecksum ? 1 : 0;
1357
+ const timestamp = new Date().toISOString();
1358
+
1359
+ if (existingEpigraph) {
1360
+ db.prepare(`
1361
+ UPDATE epigraphs
1362
+ SET epigraph_id = ?,
1363
+ chapter_id = ?,
1364
+ body = ?,
1365
+ file_path = ?,
1366
+ prose_checksum = ?,
1367
+ metadata_stale = CASE
1368
+ WHEN ? != prose_checksum THEN 1
1369
+ ELSE metadata_stale
1370
+ END,
1371
+ updated_at = ?
1372
+ WHERE epigraph_id = ? AND project_id = ?
1373
+ `).run(
1374
+ epigraphId,
1375
+ chapterId,
1376
+ prose,
1377
+ file,
1378
+ epigraphChecksum,
1379
+ epigraphChecksum,
1380
+ timestamp,
1381
+ previousEpigraphId,
1382
+ project_id
1383
+ );
1384
+ } else {
1385
+ db.prepare(`
1386
+ INSERT INTO epigraphs (
1387
+ epigraph_id, project_id, chapter_id, body, file_path, prose_checksum, metadata_stale, updated_at
1388
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
1389
+ `).run(
1390
+ epigraphId,
1391
+ project_id,
1392
+ chapterId,
1393
+ prose,
1394
+ file,
1395
+ epigraphChecksum,
1396
+ 0,
1397
+ timestamp
1398
+ );
1399
+ }
1400
+
1401
+ db.prepare(`DELETE FROM epigraph_characters WHERE epigraph_id = ? AND project_id = ?`).run(previousEpigraphId, project_id);
1402
+ db.prepare(`DELETE FROM epigraph_tags WHERE epigraph_id = ? AND project_id = ?`).run(previousEpigraphId, project_id);
1403
+ if (previousEpigraphId !== epigraphId) {
1404
+ db.prepare(`DELETE FROM epigraph_characters WHERE epigraph_id = ? AND project_id = ?`).run(epigraphId, project_id);
1405
+ db.prepare(`DELETE FROM epigraph_tags WHERE epigraph_id = ? AND project_id = ?`).run(epigraphId, project_id);
1406
+ }
1407
+ for (const characterId of (meta.characters ?? [])) {
1408
+ db.prepare(`INSERT OR IGNORE INTO epigraph_characters (epigraph_id, project_id, character_id) VALUES (?, ?, ?)`)
1409
+ .run(epigraphId, project_id, characterId);
1410
+ }
1411
+ for (const tag of (meta.tags ?? [])) {
1412
+ db.prepare(`INSERT OR IGNORE INTO epigraph_tags (epigraph_id, project_id, tag) VALUES (?, ?, ?)`)
1413
+ .run(epigraphId, project_id, tag);
1414
+ }
1415
+
1416
+ return {
1417
+ isStale: epigraphIsStale,
1418
+ skippedAsEpigraph: true,
1419
+ epigraphIndexed: true,
1420
+ chapterId,
1421
+ epigraphId,
1422
+ };
1423
+ }
1424
+
947
1425
  const newChecksum = checksumProse(prose);
948
1426
  const existing = db.prepare(
949
1427
  `SELECT prose_checksum FROM scenes WHERE scene_id = ? AND project_id = ?`
@@ -953,12 +1431,14 @@ export function indexSceneFile(db, syncDir, file, meta, prose) {
953
1431
 
954
1432
  db.prepare(`
955
1433
  INSERT INTO scenes (
956
- scene_id, project_id, title, part, chapter, chapter_title, pov, logline, scene_change,
1434
+ scene_id, project_id, chapter_id, scene_role, title, part, chapter, chapter_title, pov, logline, scene_change,
957
1435
  causality, stakes, scene_functions,
958
1436
  save_the_cat_beat, timeline_position, story_time, word_count,
959
1437
  file_path, prose_checksum, metadata_stale, updated_at
960
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1438
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
961
1439
  ON CONFLICT (scene_id, project_id) DO UPDATE SET
1440
+ chapter_id = excluded.chapter_id,
1441
+ scene_role = excluded.scene_role,
962
1442
  title = excluded.title,
963
1443
  part = excluded.part,
964
1444
  chapter = excluded.chapter,
@@ -979,7 +1459,8 @@ export function indexSceneFile(db, syncDir, file, meta, prose) {
979
1459
  updated_at = excluded.updated_at
980
1460
  `).run(
981
1461
  meta.scene_id, project_id,
982
- meta.title ?? null, meta.part ?? null, meta.chapter ?? null, meta.chapter_title ?? null,
1462
+ chapterId, meta.scene_role ?? chapterStructure.role ?? null,
1463
+ meta.title ?? null, meta.part ?? null, chapterSortIndex, chapterTitle,
983
1464
  meta.pov ?? null, meta.logline ?? meta.synopsis ?? null,
984
1465
  meta.scene_change ?? meta.change ?? null,
985
1466
  meta.causality ?? null, meta.stakes ?? null,
@@ -1076,13 +1557,14 @@ export function indexSceneFile(db, syncDir, file, meta, prose) {
1076
1557
  relation: "informs",
1077
1558
  });
1078
1559
 
1079
- return { isStale };
1560
+ return { isStale, chapterId, warning: chapterWarning };
1080
1561
  }
1081
1562
 
1082
1563
  const WARNING_TYPE_LABELS = {
1083
1564
  no_scene_id: "Skipped (no scene_id)",
1084
1565
  duplicate_scene_id: "Duplicate scene_id",
1085
1566
  path_metadata_mismatch: "Path/metadata mismatch",
1567
+ chapter_structure: "Chapter structure",
1086
1568
  orphaned_sidecar: "Orphaned sidecar",
1087
1569
  moved_scene: "Moved scene",
1088
1570
  nested_mirror: "Ignored nested mirror path",
@@ -1092,6 +1574,7 @@ const WARNING_PATTERNS = [
1092
1574
  { type: "no_scene_id", re: /^Skipped \(no scene_id\):/ },
1093
1575
  { type: "duplicate_scene_id", re: /^Duplicate scene_id/ },
1094
1576
  { type: "path_metadata_mismatch", re: /^Path\/metadata mismatch/ },
1577
+ { type: "chapter_structure", re: /^(Chapter structure warning|Epigraph requires explicit chapter linkage|Epigraph references unknown chapter_id|Scene references unknown chapter_id|Ambiguous chapter linkage|Epigraph identity conflict)/ },
1095
1578
  { type: "moved_scene", re: /^Moved scene detected:/ },
1096
1579
  { type: "orphaned_sidecar", re: /^Orphaned sidecar/ },
1097
1580
  { type: "nested_mirror", re: /^Ignored nested mirror path:/ },
@@ -1122,14 +1605,20 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
1122
1605
  const files = walkFiles(syncDir);
1123
1606
  let indexed = 0;
1124
1607
  let staleMarked = 0;
1608
+ let epigraphsIndexed = 0;
1609
+ let epigraphsStaleMarked = 0;
1125
1610
  let skipped = 0;
1126
1611
  let sidecarsMigrated = 0;
1127
1612
  const seenSceneIds = new Map(); // scene_id+project_id → file path, for duplicate detection
1128
1613
  const seenSceneKeys = new Set();
1129
1614
  const indexedSceneIds = new Set(); // scene_id only — for orphaned sidecar move detection
1615
+ const seenChapterKeys = new Set();
1616
+ const seenEpigraphKeys = new Set();
1130
1617
  const indexedReferenceDocIds = new Set();
1131
1618
  let sceneIndexFailures = 0;
1132
1619
  const warnings = [];
1620
+ const chapterFoldersByProject = new Map();
1621
+ const roleFoldersByProject = new Map();
1133
1622
 
1134
1623
  const scanFiles = [];
1135
1624
  for (const file of files) {
@@ -1178,8 +1667,9 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
1178
1667
  try {
1179
1668
  const { meta, sourceMeta, sidecarGenerated, derived, mismatches } = readMeta(file, syncDir, { writable });
1180
1669
  if (sidecarGenerated) sidecarsMigrated++;
1670
+ const chapterStructure = inferChapterStructureFromPath(syncDir, file, meta);
1181
1671
 
1182
- if (!meta.scene_id) {
1672
+ if (!meta.scene_id && !chapterStructure.isEpigraph) {
1183
1673
  skipped++;
1184
1674
  if (!quiet) warnings.push(`Skipped (no scene_id): ${path.relative(syncDir, file)}`);
1185
1675
  continue;
@@ -1188,16 +1678,27 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
1188
1678
  // Duplicate scene_id detection
1189
1679
  const { project_id } = inferProjectAndUniverse(syncDir, file);
1190
1680
  const key = `${meta.scene_id}::${project_id}`;
1191
- if (seenSceneIds.has(key)) {
1681
+ if (meta.scene_id && seenSceneIds.has(key)) {
1192
1682
  warnings.push(
1193
1683
  `Duplicate scene_id "${meta.scene_id}" in project "${project_id}":\n` +
1194
1684
  ` ${path.relative(syncDir, seenSceneIds.get(key))}\n` +
1195
1685
  ` ${path.relative(syncDir, file)}`
1196
1686
  );
1197
- } else {
1687
+ } else if (meta.scene_id) {
1198
1688
  seenSceneIds.set(key, file);
1199
1689
  }
1200
- seenSceneKeys.add(key);
1690
+ if (chapterStructure.role) {
1691
+ const roleKey = `${project_id}::${chapterStructure.role}`;
1692
+ const existingRoleFolder = roleFoldersByProject.get(roleKey);
1693
+ const currentRoleFolder = path.dirname(file);
1694
+ if (!existingRoleFolder) {
1695
+ roleFoldersByProject.set(roleKey, currentRoleFolder);
1696
+ } else if (existingRoleFolder !== currentRoleFolder) {
1697
+ warnings.push(
1698
+ `Chapter structure warning: multiple ${chapterStructure.role} folders in project "${project_id}": ${path.relative(syncDir, existingRoleFolder)} and ${path.relative(syncDir, currentRoleFolder)}.`
1699
+ );
1700
+ }
1701
+ }
1201
1702
 
1202
1703
  if (mismatches.part || mismatches.chapter) {
1203
1704
  const details = [];
@@ -1209,7 +1710,40 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
1209
1710
  }
1210
1711
 
1211
1712
  const { data: _frontmatter, content: prose } = parseFile(file);
1212
- const { isStale } = indexSceneFile(db, syncDir, file, meta, prose);
1713
+ const result = indexSceneFile(db, syncDir, file, meta, prose);
1714
+ if (result.warning) {
1715
+ warnings.push(result.warning);
1716
+ }
1717
+ if (result.chapterId) {
1718
+ seenChapterKeys.add(`${result.chapterId}::${project_id}`);
1719
+ }
1720
+ if (chapterStructure.chapter && result.chapterId) {
1721
+ const chapterMapKey = `${project_id}::${chapterStructure.chapter.sort_index}`;
1722
+ const existingChapterFolder = chapterFoldersByProject.get(chapterMapKey);
1723
+ if (!existingChapterFolder) {
1724
+ chapterFoldersByProject.set(chapterMapKey, {
1725
+ title: chapterStructure.chapter.title,
1726
+ folder_key: chapterStructure.chapter.folder_key,
1727
+ });
1728
+ } else if (existingChapterFolder.folder_key !== chapterStructure.chapter.folder_key) {
1729
+ warnings.push(
1730
+ `Chapter structure warning: duplicate chapter order ${chapterStructure.chapter.sort_index} in project "${project_id}" for ${chapterStructure.chapter.folder_key} and ${existingChapterFolder.folder_key}.`
1731
+ );
1732
+ }
1733
+ }
1734
+ if (result.skippedAsEpigraph) {
1735
+ if (result.epigraphIndexed && result.epigraphId) {
1736
+ const epigraphId = result.epigraphId;
1737
+ seenEpigraphKeys.add(`${epigraphId}::${project_id}`);
1738
+ }
1739
+ if (result.epigraphIndexed) {
1740
+ epigraphsIndexed++;
1741
+ if (result.isStale) epigraphsStaleMarked++;
1742
+ }
1743
+ continue;
1744
+ }
1745
+ const { isStale } = result;
1746
+ if (meta.scene_id) seenSceneKeys.add(key);
1213
1747
  indexedSceneIds.add(meta.scene_id);
1214
1748
  if (isStale) staleMarked++;
1215
1749
  indexed++;
@@ -1221,6 +1755,8 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
1221
1755
 
1222
1756
  if (canPruneScenes(syncDir) && sceneIndexFailures === 0) {
1223
1757
  pruneMissingScenes(db, seenSceneKeys, syncDir);
1758
+ pruneMissingEpigraphs(db, seenEpigraphKeys, syncDir);
1759
+ pruneMissingChapters(db, seenChapterKeys, syncDir);
1224
1760
  }
1225
1761
 
1226
1762
  // --- Orphaned sidecar detection ---
@@ -1252,7 +1788,8 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
1252
1788
 
1253
1789
  if (!quiet) {
1254
1790
  process.stderr.write(
1255
- `[mcp-writing] Sync complete: ${indexed} scenes indexed, ${staleMarked} marked stale` +
1791
+ `[mcp-writing] Sync complete: ${indexed} scenes indexed, ${staleMarked} scenes marked stale` +
1792
+ (epigraphsIndexed ? `, ${epigraphsIndexed} epigraphs indexed, ${epigraphsStaleMarked} epigraphs marked stale` : "") +
1256
1793
  (sidecarsMigrated ? `, ${sidecarsMigrated} sidecars auto-generated` : "") +
1257
1794
  (skipped ? `, ${skipped} files skipped` : "") + "\n"
1258
1795
  );
@@ -1262,5 +1799,14 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
1262
1799
  process.stderr.write(`[mcp-writing] WARNING: ${label}: ${count} file(s). First example: ${entry.examples[0]}\n`);
1263
1800
  }
1264
1801
  }
1265
- return { indexed, staleMarked, skipped, sidecarsMigrated, warnings, warningSummary };
1802
+ return {
1803
+ indexed,
1804
+ staleMarked,
1805
+ epigraphsIndexed,
1806
+ epigraphsStaleMarked,
1807
+ skipped,
1808
+ sidecarsMigrated,
1809
+ warnings,
1810
+ warningSummary,
1811
+ };
1266
1812
  }