@hanna84/mcp-writing 3.9.4 → 3.10.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/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.10.0](https://github.com/hannasdev/mcp-writing/compare/v3.9.5...v3.10.0)
8
+
9
+ - feat: add read-only structure diagnostics [`#202`](https://github.com/hannasdev/mcp-writing/pull/202)
10
+
11
+ #### [v3.9.5](https://github.com/hannasdev/mcp-writing/compare/v3.9.4...v3.9.5)
12
+
13
+ > 18 May 2026
14
+
15
+ - refactor(sync): complete target architecture M3 [`#201`](https://github.com/hannasdev/mcp-writing/pull/201)
16
+ - Release 3.9.5 [`9cd308a`](https://github.com/hannasdev/mcp-writing/commit/9cd308aa838a7c3992183ee8c0acce53d61f75cd)
17
+
7
18
  #### [v3.9.4](https://github.com/hannasdev/mcp-writing/compare/v3.9.3...v3.9.4)
8
19
 
20
+ > 17 May 2026
21
+
9
22
  - refactor(structure): centralize scene structure patches [`#200`](https://github.com/hannasdev/mcp-writing/pull/200)
23
+ - Release 3.9.4 [`ac77e80`](https://github.com/hannasdev/mcp-writing/commit/ac77e801d94ca576923a46a6d22c8f3230988d76)
10
24
 
11
25
  #### [v3.9.3](https://github.com/hannasdev/mcp-writing/compare/v3.9.2...v3.9.3)
12
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanna84/mcp-writing",
3
- "version": "3.9.4",
3
+ "version": "3.10.0",
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",
@@ -0,0 +1,380 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import matter from "gray-matter";
4
+ import yaml from "js-yaml";
5
+ import {
6
+ inferChapterStructureFromPath,
7
+ normalizeSceneMetaForPath,
8
+ } from "./structure-inference.js";
9
+
10
+ function sidecarPath(filePath) {
11
+ return filePath.replace(/\.(md|txt)$/, ".meta.yaml");
12
+ }
13
+
14
+ function normalizeRelativePath(syncDir, filePath) {
15
+ return path.relative(syncDir, filePath).split(path.sep).join("/");
16
+ }
17
+
18
+ function isPathInsideSyncDir(syncDir, filePath) {
19
+ const relativePath = path.relative(path.resolve(syncDir), path.resolve(filePath));
20
+ return relativePath !== "" && !relativePath.startsWith("..") && !path.isAbsolute(relativePath);
21
+ }
22
+
23
+ function readStructureMetadata(filePath) {
24
+ const sidecar = sidecarPath(filePath);
25
+ if (fs.existsSync(sidecar)) {
26
+ const raw = fs.readFileSync(sidecar, "utf8");
27
+ return yaml.load(raw) ?? {};
28
+ }
29
+
30
+ const raw = fs.readFileSync(filePath, "utf8");
31
+ return matter(raw).data ?? {};
32
+ }
33
+
34
+ function readObservedStructure(syncDir, filePath) {
35
+ const sourceMeta = readStructureMetadata(filePath);
36
+ const { meta } = normalizeSceneMetaForPath(syncDir, filePath, sourceMeta);
37
+ return {
38
+ meta,
39
+ chapterStructure: inferChapterStructureFromPath(syncDir, filePath, meta),
40
+ };
41
+ }
42
+
43
+ function createDiagnostic(type, message, details = {}, {
44
+ severity = "warning",
45
+ nextStep = null,
46
+ } = {}) {
47
+ return {
48
+ type,
49
+ severity,
50
+ message,
51
+ details,
52
+ ...(nextStep ? { next_step: nextStep } : {}),
53
+ };
54
+ }
55
+
56
+ function addDiagnostic(diagnostics, type, message, details = {}, options = {}) {
57
+ diagnostics.push(createDiagnostic(type, message, details, options));
58
+ }
59
+
60
+ function countBy(items, key) {
61
+ const result = {};
62
+ for (const item of items) {
63
+ const value = item[key] ?? "unknown";
64
+ result[value] = (result[value] ?? 0) + 1;
65
+ }
66
+ return result;
67
+ }
68
+
69
+ function projectClause(projectId, alias = "") {
70
+ const prefix = alias ? `${alias}.` : "";
71
+ return projectId ? { sql: ` AND ${prefix}project_id = ?`, params: [projectId] } : { sql: "", params: [] };
72
+ }
73
+
74
+ function readIndexedSceneRows(db, projectId) {
75
+ const scope = projectClause(projectId);
76
+ return db.prepare(`
77
+ SELECT scene_id, project_id, chapter_id, scene_role, chapter, chapter_title, file_path
78
+ FROM scenes
79
+ WHERE 1 = 1${scope.sql}
80
+ ORDER BY project_id, scene_id
81
+ `).all(...scope.params);
82
+ }
83
+
84
+ function readIndexedEpigraphRows(db, projectId) {
85
+ const scope = projectClause(projectId);
86
+ return db.prepare(`
87
+ SELECT epigraph_id, project_id, chapter_id, file_path
88
+ FROM epigraphs
89
+ WHERE 1 = 1${scope.sql}
90
+ ORDER BY project_id, epigraph_id
91
+ `).all(...scope.params);
92
+ }
93
+
94
+ function diagnoseUnknownChapterLinks(db, diagnostics, projectId) {
95
+ const sceneScope = projectClause(projectId, "s");
96
+ const scenes = db.prepare(`
97
+ SELECT s.scene_id, s.project_id, s.chapter_id, s.file_path
98
+ FROM scenes s
99
+ LEFT JOIN chapters c ON c.project_id = s.project_id AND c.chapter_id = s.chapter_id
100
+ WHERE s.chapter_id IS NOT NULL AND s.chapter_id != '' AND c.chapter_id IS NULL${sceneScope.sql}
101
+ ORDER BY s.project_id, s.scene_id
102
+ `).all(...sceneScope.params);
103
+
104
+ for (const scene of scenes) {
105
+ addDiagnostic(
106
+ diagnostics,
107
+ "scene_unknown_chapter",
108
+ `Scene "${scene.scene_id}" in project "${scene.project_id}" references unknown chapter_id "${scene.chapter_id}".`,
109
+ {
110
+ project_id: scene.project_id,
111
+ scene_id: scene.scene_id,
112
+ chapter_id: scene.chapter_id,
113
+ file_path: scene.file_path,
114
+ },
115
+ { nextStep: "Run sync to refresh canonical chapter indexes, then use an explicit structure workflow once available if the link is still invalid." }
116
+ );
117
+ }
118
+
119
+ const epigraphScope = projectClause(projectId, "e");
120
+ const epigraphs = db.prepare(`
121
+ SELECT e.epigraph_id, e.project_id, e.chapter_id, e.file_path
122
+ FROM epigraphs e
123
+ LEFT JOIN chapters c ON c.project_id = e.project_id AND c.chapter_id = e.chapter_id
124
+ WHERE c.chapter_id IS NULL${epigraphScope.sql}
125
+ ORDER BY e.project_id, e.epigraph_id
126
+ `).all(...epigraphScope.params);
127
+
128
+ for (const epigraph of epigraphs) {
129
+ addDiagnostic(
130
+ diagnostics,
131
+ "epigraph_unknown_chapter",
132
+ `Epigraph "${epigraph.epigraph_id}" in project "${epigraph.project_id}" references unknown chapter_id "${epigraph.chapter_id}".`,
133
+ {
134
+ project_id: epigraph.project_id,
135
+ epigraph_id: epigraph.epigraph_id,
136
+ chapter_id: epigraph.chapter_id,
137
+ file_path: epigraph.file_path,
138
+ },
139
+ { nextStep: "Check the epigraph file path and chapter sidecar fields before applying any repair." }
140
+ );
141
+ }
142
+ }
143
+
144
+ function diagnoseNumericCompatibility(db, diagnostics, projectId) {
145
+ const scope = projectClause(projectId, "s");
146
+ const rows = db.prepare(`
147
+ SELECT s.scene_id, s.project_id, s.chapter_id, s.chapter, s.chapter_title,
148
+ c.sort_index, c.title
149
+ FROM scenes s
150
+ JOIN chapters c ON c.project_id = s.project_id AND c.chapter_id = s.chapter_id
151
+ WHERE (
152
+ (s.chapter IS NOT NULL AND s.chapter != c.sort_index)
153
+ OR (s.chapter_title IS NOT NULL AND s.chapter_title != c.title)
154
+ )${scope.sql}
155
+ ORDER BY s.project_id, s.scene_id
156
+ `).all(...scope.params);
157
+
158
+ for (const row of rows) {
159
+ addDiagnostic(
160
+ diagnostics,
161
+ "numeric_chapter_identity_mismatch",
162
+ `Scene "${row.scene_id}" compatibility chapter fields disagree with canonical chapter "${row.chapter_id}".`,
163
+ {
164
+ project_id: row.project_id,
165
+ scene_id: row.scene_id,
166
+ chapter_id: row.chapter_id,
167
+ scene_chapter: row.chapter,
168
+ canonical_chapter: row.sort_index,
169
+ scene_chapter_title: row.chapter_title,
170
+ canonical_chapter_title: row.title,
171
+ },
172
+ { nextStep: "Prefer canonical chapter_id in workflows; update compatibility fields only through sanctioned structure paths." }
173
+ );
174
+ }
175
+ }
176
+
177
+ function diagnoseObservedFiles(syncDir, diagnostics, { scenes, epigraphs }) {
178
+ const observedChapterFolders = new Map();
179
+ const roleFolders = new Map();
180
+
181
+ for (const scene of scenes) {
182
+ if (!scene.file_path || !fs.existsSync(scene.file_path)) continue;
183
+ if (!isPathInsideSyncDir(syncDir, scene.file_path)) {
184
+ addDiagnostic(
185
+ diagnostics,
186
+ "indexed_path_outside_sync_root",
187
+ `Scene "${scene.scene_id}" has an indexed file path outside the active sync root.`,
188
+ {
189
+ project_id: scene.project_id,
190
+ scene_id: scene.scene_id,
191
+ file_path: scene.file_path,
192
+ sync_dir: syncDir,
193
+ },
194
+ { nextStep: "Run sync with the current sync root before trusting file-derived structure diagnostics." }
195
+ );
196
+ continue;
197
+ }
198
+
199
+ let observed;
200
+ try {
201
+ observed = readObservedStructure(syncDir, scene.file_path);
202
+ } catch (error) {
203
+ addDiagnostic(
204
+ diagnostics,
205
+ "structure_file_read_failed",
206
+ `Could not read structure metadata for scene "${scene.scene_id}": ${error.message}`,
207
+ {
208
+ project_id: scene.project_id,
209
+ scene_id: scene.scene_id,
210
+ file_path: scene.file_path,
211
+ },
212
+ { severity: "info", nextStep: "Run sync after confirming the file still exists and has readable metadata." }
213
+ );
214
+ continue;
215
+ }
216
+
217
+ const chapter = observed.chapterStructure.chapter;
218
+ if (chapter) {
219
+ const key = `${scene.project_id}::${chapter.sort_index}`;
220
+ const existing = observedChapterFolders.get(key) ?? new Set();
221
+ existing.add(chapter.folder_key);
222
+ observedChapterFolders.set(key, existing);
223
+
224
+ if (scene.chapter_id && scene.chapter_id !== chapter.chapter_id) {
225
+ addDiagnostic(
226
+ diagnostics,
227
+ "folder_canonical_mismatch",
228
+ `Scene "${scene.scene_id}" is indexed to chapter_id "${scene.chapter_id}" but its folder implies "${chapter.chapter_id}".`,
229
+ {
230
+ project_id: scene.project_id,
231
+ scene_id: scene.scene_id,
232
+ indexed_chapter_id: scene.chapter_id,
233
+ observed_chapter_id: chapter.chapter_id,
234
+ observed_chapter: chapter.sort_index,
235
+ relative_path: normalizeRelativePath(syncDir, scene.file_path),
236
+ },
237
+ { nextStep: "Inspect the file location and sidecar before changing canonical chapter links." }
238
+ );
239
+ }
240
+ }
241
+
242
+ if (observed.chapterStructure.role && ["prologue", "epilogue"].includes(observed.chapterStructure.role)) {
243
+ const key = `${scene.project_id}::${observed.chapterStructure.role}`;
244
+ const existing = roleFolders.get(key) ?? new Set();
245
+ existing.add(path.dirname(scene.file_path));
246
+ roleFolders.set(key, existing);
247
+ }
248
+ }
249
+
250
+ for (const [key, folders] of observedChapterFolders.entries()) {
251
+ if (folders.size <= 1) continue;
252
+ const [projectId, sortIndex] = key.split("::");
253
+ addDiagnostic(
254
+ diagnostics,
255
+ "duplicate_chapter_sort_index",
256
+ `Project "${projectId}" has multiple observed folders for chapter order ${sortIndex}.`,
257
+ {
258
+ project_id: projectId,
259
+ chapter: Number(sortIndex),
260
+ folders: [...folders].sort(),
261
+ },
262
+ { nextStep: "Resolve the duplicate folder-derived chapter order before relying on canonical structure diagnostics." }
263
+ );
264
+ }
265
+
266
+ for (const [key, folders] of roleFolders.entries()) {
267
+ if (folders.size <= 1) continue;
268
+ const [projectId, role] = key.split("::");
269
+ addDiagnostic(
270
+ diagnostics,
271
+ "multiple_scene_role",
272
+ `Project "${projectId}" has multiple observed ${role} folders.`,
273
+ {
274
+ project_id: projectId,
275
+ scene_role: role,
276
+ folders: [...folders].sort().map(folder => normalizeRelativePath(syncDir, folder)),
277
+ },
278
+ { nextStep: `Decide which ${role} folder is canonical before running repair or mutation workflows.` }
279
+ );
280
+ }
281
+
282
+ for (const epigraph of epigraphs) {
283
+ if (!epigraph.file_path || !fs.existsSync(epigraph.file_path)) continue;
284
+ if (!isPathInsideSyncDir(syncDir, epigraph.file_path)) {
285
+ addDiagnostic(
286
+ diagnostics,
287
+ "indexed_path_outside_sync_root",
288
+ `Epigraph "${epigraph.epigraph_id}" has an indexed file path outside the active sync root.`,
289
+ {
290
+ project_id: epigraph.project_id,
291
+ epigraph_id: epigraph.epigraph_id,
292
+ file_path: epigraph.file_path,
293
+ sync_dir: syncDir,
294
+ },
295
+ { nextStep: "Run sync with the current sync root before trusting file-derived structure diagnostics." }
296
+ );
297
+ continue;
298
+ }
299
+
300
+ let observed;
301
+ try {
302
+ observed = readObservedStructure(syncDir, epigraph.file_path);
303
+ } catch (error) {
304
+ addDiagnostic(
305
+ diagnostics,
306
+ "structure_file_read_failed",
307
+ `Could not read structure metadata for epigraph "${epigraph.epigraph_id}": ${error.message}`,
308
+ {
309
+ project_id: epigraph.project_id,
310
+ epigraph_id: epigraph.epigraph_id,
311
+ file_path: epigraph.file_path,
312
+ },
313
+ { severity: "info", nextStep: "Run sync after confirming the epigraph file still exists and has readable metadata." }
314
+ );
315
+ continue;
316
+ }
317
+
318
+ const observedChapterId = observed.chapterStructure.chapter?.chapter_id ?? observed.meta.chapter_id ?? null;
319
+ if (observedChapterId && epigraph.chapter_id !== observedChapterId) {
320
+ addDiagnostic(
321
+ diagnostics,
322
+ "epigraph_chapter_conflict",
323
+ `Epigraph "${epigraph.epigraph_id}" is indexed to chapter_id "${epigraph.chapter_id}" but its file implies "${observedChapterId}".`,
324
+ {
325
+ project_id: epigraph.project_id,
326
+ epigraph_id: epigraph.epigraph_id,
327
+ indexed_chapter_id: epigraph.chapter_id,
328
+ observed_chapter_id: observedChapterId,
329
+ relative_path: normalizeRelativePath(syncDir, epigraph.file_path),
330
+ },
331
+ { nextStep: "Inspect the epigraph sidecar and folder before reassigning it." }
332
+ );
333
+ }
334
+ }
335
+ }
336
+
337
+ export function runStructureDiagnostics(db, {
338
+ syncDir,
339
+ projectId = null,
340
+ } = {}) {
341
+ const diagnostics = [];
342
+ const scenes = readIndexedSceneRows(db, projectId);
343
+ const epigraphs = readIndexedEpigraphRows(db, projectId);
344
+
345
+ diagnoseUnknownChapterLinks(db, diagnostics, projectId);
346
+ diagnoseNumericCompatibility(db, diagnostics, projectId);
347
+
348
+ if (syncDir) {
349
+ diagnoseObservedFiles(syncDir, diagnostics, { scenes, epigraphs });
350
+ }
351
+
352
+ diagnostics.sort((a, b) => {
353
+ const projectCompare = String(a.details.project_id ?? "").localeCompare(String(b.details.project_id ?? ""));
354
+ if (projectCompare) return projectCompare;
355
+ const typeCompare = a.type.localeCompare(b.type);
356
+ if (typeCompare) return typeCompare;
357
+ return a.message.localeCompare(b.message);
358
+ });
359
+
360
+ return {
361
+ ok: diagnostics.length === 0,
362
+ checked: {
363
+ project_id: projectId,
364
+ scenes: scenes.length,
365
+ epigraphs: epigraphs.length,
366
+ },
367
+ summary: {
368
+ total: diagnostics.length,
369
+ by_type: countBy(diagnostics, "type"),
370
+ by_severity: countBy(diagnostics, "severity"),
371
+ },
372
+ diagnostics,
373
+ next_steps: diagnostics.length
374
+ ? [
375
+ "Review diagnostics before applying any structure repair.",
376
+ "Run sync after external file moves, then re-run diagnose_structure to confirm remaining drift.",
377
+ ]
378
+ : ["No structure drift detected in the current index."],
379
+ };
380
+ }
package/src/sync/sync.js CHANGED
@@ -74,11 +74,34 @@ export function walkSidecars(dir, fileList = []) {
74
74
  return fileList;
75
75
  }
76
76
 
77
+ export function buildSyncDiagnostic(message, { type = null, ...details } = {}) {
78
+ return { type, message, ...details };
79
+ }
80
+
77
81
  function isNestedMirrorPath(syncDir, filePath) {
78
82
  const rel = path.relative(syncDir, filePath).split(path.sep).join("/");
79
83
  return rel.includes("/scenes/projects/") || rel.includes("/scenes/universes/");
80
84
  }
81
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
+
82
105
  export function sidecarPath(filePath) {
83
106
  return filePath.replace(/\.(md|txt)$/, ".meta.yaml");
84
107
  }
@@ -813,8 +836,14 @@ export function indexReferenceFile(db, syncDir, file, meta = {}, content = "") {
813
836
  return docId;
814
837
  }
815
838
 
816
- function pruneMissingReferenceDocs(db, seenDocIds) {
817
- 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
+
818
847
  for (const row of rows) {
819
848
  if (seenDocIds.has(row.doc_id)) continue;
820
849
  db.prepare(`
@@ -946,9 +975,157 @@ function pruneMissingEpigraphs(db, seenEpigraphKeys, syncDir) {
946
975
  }
947
976
  }
948
977
 
949
- 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
+ } = {}) {
950
1044
  const { universe_id, project_id } = inferProjectAndUniverse(syncDir, file);
1045
+ const relativePath = path.relative(syncDir, file);
951
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;
952
1129
  const referenceIds = normalizeReferenceIdList(meta.reference_ids ?? meta.references);
953
1130
  const explicitSceneLinks = collectExplicitReferenceLinks(
954
1131
  meta,
@@ -965,22 +1142,13 @@ export function indexSceneFile(db, syncDir, file, meta, prose) {
965
1142
  project_id, universe_id ?? null, project_id
966
1143
  );
967
1144
 
968
- const relativePath = path.relative(syncDir, file);
969
- const chapterResolution = resolveIndexedChapterForFile(db, {
970
- syncDir,
971
- projectId: project_id,
972
- filePath: file,
973
- relativePath,
974
- meta,
975
- chapterStructure,
976
- });
977
1145
  const {
978
1146
  chapterId,
979
1147
  chapterSortIndex,
980
1148
  chapterTitle,
981
1149
  chapterWarning,
982
1150
  upsertChapter,
983
- } = chapterResolution;
1151
+ } = canonicalIndexPlan.chapterResolution;
984
1152
 
985
1153
  if (upsertChapter) {
986
1154
  upsertCanonicalChapterRecord(db, {
@@ -991,7 +1159,7 @@ export function indexSceneFile(db, syncDir, file, meta, prose) {
991
1159
  }
992
1160
 
993
1161
  if (chapterStructure.isEpigraph) {
994
- return indexCanonicalEpigraph(db, {
1162
+ const result = indexCanonicalEpigraph(db, {
995
1163
  projectId: project_id,
996
1164
  chapterId,
997
1165
  chapterSortIndex,
@@ -999,11 +1167,12 @@ export function indexSceneFile(db, syncDir, file, meta, prose) {
999
1167
  meta,
1000
1168
  prose,
1001
1169
  file,
1002
- relativePath,
1170
+ relativePath: canonicalIndexPlan.observedStructure.relativePath,
1003
1171
  chapterWarning,
1004
1172
  buildProseChecksum: checksumProse,
1005
1173
  buildDefaultEpigraphId: ({ projectId, chapterId }) => `epi-${slugifyChapterValue(`${projectId}-${chapterId}`)}`,
1006
1174
  });
1175
+ return { ...result, canonicalIndexPlan };
1007
1176
  }
1008
1177
 
1009
1178
  const newChecksum = checksumProse(prose);
@@ -1141,7 +1310,48 @@ export function indexSceneFile(db, syncDir, file, meta, prose) {
1141
1310
  relation: "informs",
1142
1311
  });
1143
1312
 
1144
- 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;
1145
1355
  }
1146
1356
 
1147
1357
  const WARNING_TYPE_LABELS = {
@@ -1166,7 +1376,7 @@ const WARNING_PATTERNS = [
1166
1376
 
1167
1377
  const MAX_WARNING_EXAMPLES = 5;
1168
1378
 
1169
- function buildWarningSummary(warnings) {
1379
+ export function buildWarningSummary(warnings) {
1170
1380
  const summary = {};
1171
1381
  for (const w of warnings) {
1172
1382
  const firstLine = w.split("\n")[0];
@@ -1186,7 +1396,6 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
1186
1396
  // (for example after imports or path repairs) are reflected immediately.
1187
1397
  UNIVERSE_PROJECT_ROOT_CACHE.clear();
1188
1398
 
1189
- const files = walkFiles(syncDir);
1190
1399
  let indexed = 0;
1191
1400
  let staleMarked = 0;
1192
1401
  let epigraphsIndexed = 0;
@@ -1198,69 +1407,43 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
1198
1407
  const indexedSceneIds = new Set(); // scene_id only — for orphaned sidecar move detection
1199
1408
  const seenChapterKeys = new Set();
1200
1409
  const seenEpigraphKeys = new Set();
1201
- const indexedReferenceDocIds = new Set();
1202
1410
  let sceneIndexFailures = 0;
1203
1411
  const warnings = [];
1204
1412
  const chapterFoldersByProject = new Map();
1205
1413
  const roleFoldersByProject = new Map();
1206
1414
 
1207
- const scanFiles = [];
1208
- for (const file of files) {
1209
- if (isNestedMirrorPath(syncDir, file)) {
1210
- warnings.push(`Ignored nested mirror path: ${path.relative(syncDir, file)}`);
1211
- continue;
1212
- }
1213
- 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);
1214
1419
  }
1215
1420
 
1216
1421
  // --- Pass 1: world files and reference docs (characters/places must be indexed
1217
1422
  // before scenes so that character name -> ID resolution in scene_characters works) ---
1218
- for (const file of scanFiles) {
1219
- if (isReferenceFile(syncDir, file)) {
1220
- try {
1221
- const { data, content } = parseFile(file);
1222
- const docId = indexReferenceFile(db, syncDir, file, data, content);
1223
- indexedReferenceDocIds.add(docId);
1224
- } catch (err) {
1225
- process.stderr.write(`[mcp-writing] Failed to index ${file}: ${err.message}\n`);
1226
- }
1227
- continue;
1228
- }
1229
-
1230
- if (!isWorldFile(syncDir, file)) continue;
1231
- try {
1232
- const { meta } = readMeta(file, syncDir, { writable });
1233
- if (!Object.keys(meta).length) {
1234
- const { data } = parseFile(file);
1235
- indexWorldFile(db, syncDir, file, data);
1236
- } else {
1237
- indexWorldFile(db, syncDir, file, meta);
1238
- }
1239
- } catch (err) {
1240
- process.stderr.write(`[mcp-writing] Failed to index ${file}: ${err.message}\n`);
1241
- }
1242
- }
1243
-
1244
- if (canPruneReferenceDocs(syncDir)) {
1245
- pruneMissingReferenceDocs(db, indexedReferenceDocIds);
1246
- }
1423
+ regenerateReferenceAndWorldIndexes(db, syncDir, scanFiles, { writable, pruneReferenceDocs: true });
1247
1424
 
1248
1425
  // --- Pass 2: scene files ---
1249
1426
  for (const file of scanFiles) {
1250
1427
  if (isWorldFile(syncDir, file) || isReferenceFile(syncDir, file)) continue;
1251
1428
  try {
1252
- const { meta, sourceMeta, sidecarGenerated, derived, mismatches } = readMeta(file, syncDir, { writable });
1429
+ const { meta, sourceMeta, sidecarGenerated, derived, mismatches } = readSceneMetadataForSync(syncDir, file, { writable });
1253
1430
  if (sidecarGenerated) sidecarsMigrated++;
1254
- 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;
1255
1438
 
1256
1439
  if (!meta.scene_id && !chapterStructure.isEpigraph) {
1257
1440
  skipped++;
1258
- if (!quiet) warnings.push(`Skipped (no scene_id): ${path.relative(syncDir, file)}`);
1441
+ if (!quiet) warnings.push(`Skipped (no scene_id): ${structureObservation.relativePath}`);
1259
1442
  continue;
1260
1443
  }
1261
1444
 
1262
1445
  // Duplicate scene_id detection
1263
- const { project_id } = inferProjectAndUniverse(syncDir, file);
1446
+ const project_id = structureObservation.projectId;
1264
1447
  const key = `${meta.scene_id}::${project_id}`;
1265
1448
  if (meta.scene_id && seenSceneIds.has(key)) {
1266
1449
  warnings.push(
@@ -1284,18 +1467,17 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
1284
1467
  }
1285
1468
  }
1286
1469
 
1287
- if (mismatches.part || mismatches.chapter) {
1288
- const details = [];
1289
- if (mismatches.part) details.push(`part metadata ${sourceMeta.part} != path part ${derived.part}`);
1290
- if (mismatches.chapter) details.push(`chapter metadata ${sourceMeta.chapter} != path chapter ${derived.chapter}`);
1291
- warnings.push(
1292
- `Path/metadata mismatch for scene "${meta.scene_id}": ${path.relative(syncDir, file)} (${details.join(", ")}). Using path-derived values.`
1293
- );
1470
+ for (const diagnostic of structureObservation.diagnostics) {
1471
+ warnings.push(diagnostic.message);
1294
1472
  }
1295
1473
 
1296
- const { data: _frontmatter, content: prose } = parseFile(file);
1297
- const result = indexSceneFile(db, syncDir, file, meta, prose);
1298
- 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) {
1299
1481
  warnings.push(result.warning);
1300
1482
  }
1301
1483
  if (result.chapterId) {
@@ -1337,35 +1519,16 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
1337
1519
  }
1338
1520
  }
1339
1521
 
1340
- if (canPruneScenes(syncDir) && sceneIndexFailures === 0) {
1341
- pruneMissingScenes(db, seenSceneKeys, syncDir);
1342
- pruneMissingEpigraphs(db, seenEpigraphKeys, syncDir);
1343
- pruneMissingChapters(db, seenChapterKeys, syncDir);
1344
- }
1522
+ pruneSyncDerivedIndexes(db, syncDir, {
1523
+ seenSceneKeys,
1524
+ seenEpigraphKeys,
1525
+ seenChapterKeys,
1526
+ sceneIndexFailures,
1527
+ });
1345
1528
 
1346
1529
  // --- Orphaned sidecar detection ---
1347
- const sidecars = walkSidecars(syncDir).filter(sidecar => !isNestedMirrorPath(syncDir, sidecar));
1348
- for (const sidecar of sidecars) {
1349
- const prose = sidecar.replace(/\.meta\.yaml$/, ".md");
1350
- const proseTxt = sidecar.replace(/\.meta\.yaml$/, ".txt");
1351
- if (!fs.existsSync(prose) && !fs.existsSync(proseTxt)) {
1352
- let orphanedSceneId = null;
1353
- try {
1354
- const raw = fs.readFileSync(sidecar, "utf8");
1355
- orphanedSceneId = (parseYaml(raw) ?? {}).scene_id ?? null;
1356
- } catch { /* empty */ }
1357
-
1358
- if (orphanedSceneId && indexedSceneIds.has(orphanedSceneId)) {
1359
- warnings.push(
1360
- `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.`
1361
- );
1362
- } else {
1363
- const label = orphanedSceneId ? `scene "${orphanedSceneId}"` : "unknown scene";
1364
- warnings.push(
1365
- `Orphaned sidecar (${label}, no matching .md/.txt and not indexed): ${path.relative(syncDir, sidecar)}`
1366
- );
1367
- }
1368
- }
1530
+ for (const diagnostic of observeOrphanedSidecars(syncDir, { indexedSceneIds })) {
1531
+ warnings.push(diagnostic.message);
1369
1532
  }
1370
1533
 
1371
1534
  const warningSummary = buildWarningSummary(warnings);
package/src/tools/sync.js CHANGED
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import matter from "gray-matter";
5
5
  import { syncAll, writeMeta, readMeta, indexSceneFile, normalizeSceneMetaForPath } from "../sync/sync.js";
6
6
  import { importScrivenerSync, validateProjectId } from "../sync/importer.js";
7
+ import { runStructureDiagnostics } from "../structure/structure-diagnostics.js";
7
8
 
8
9
  export function registerSyncTools(s, {
9
10
  db,
@@ -45,6 +46,27 @@ export function registerSyncTools(s, {
45
46
  return { content: [{ type: "text", text: parts.join(" ") }] };
46
47
  });
47
48
 
49
+ s.tool(
50
+ "diagnose_structure",
51
+ "Run read-only structure diagnostics against the current index and sync files. Reports canonical drift, ambiguous folder-derived structure, unknown chapter links, epigraph conflicts, and compatibility chapter mismatches without repairing files or mutating the database.",
52
+ {
53
+ project_id: z.string().optional().describe("Optional project ID to limit diagnostics to one project."),
54
+ },
55
+ async ({ project_id } = {}) => {
56
+ if (project_id !== undefined) {
57
+ const projectIdCheck = validateProjectId(project_id);
58
+ if (!projectIdCheck.ok) {
59
+ return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
60
+ }
61
+ }
62
+
63
+ return jsonResponse(runStructureDiagnostics(db, {
64
+ syncDir: SYNC_DIR,
65
+ projectId: project_id ?? null,
66
+ }));
67
+ }
68
+ );
69
+
48
70
  s.tool(
49
71
  "import_scrivener_sync",
50
72
  "[STABLE] Import Scrivener External Folder Sync Draft files into this server's WRITING_SYNC_DIR by generating scene sidecars and reconciling by Scrivener binder ID. This is the recommended default path for first-time setup before sync().",
@@ -69,6 +69,7 @@ export const WORKFLOW_CATALOGUE = [
69
69
  use_when: "Use when new material has been added, sync/import reveals metadata gaps, or normal work touches scenes or documents with weak, stale, or missing metadata support.",
70
70
  steps: [
71
71
  { tool: "sync", note: "Refresh the index and use the result as the main signal that material has changed or parity may need attention." },
72
+ { tool: "diagnose_structure", note: "Use when sync warning summaries suggest chapter, epigraph, or folder-derived structure drift that should be understood before repair." },
72
73
  { tool: "enrich_scene", note: "Use for lightweight opportunistic recovery when the current task is already touching a specific low-parity scene." },
73
74
  { tool: "enrich_scene_characters_batch", note: "Use when recovery scope is broad enough to justify focused catch-up work; prefer dry_run first." },
74
75
  { tool: "suggest_scene_references", note: "Use when low parity is specifically about missing scene-to-reference relationships." },
@@ -90,6 +91,7 @@ export const WORKFLOW_CATALOGUE = [
90
91
  steps: [
91
92
  { tool: "get_runtime_config", note: "Verify sync dir, writability, and git availability." },
92
93
  { tool: "sync", note: "Index scenes from disk so the main manuscript workflows can operate." },
94
+ { tool: "diagnose_structure", note: "Run after sync when connecting existing projects to inspect structure drift without repairing anything." },
93
95
  ],
94
96
  },
95
97
  {