@hanna84/mcp-writing 3.9.5 → 3.11.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.11.0](https://github.com/hannasdev/mcp-writing/compare/v3.10.0...v3.11.0)
8
+
9
+ - feat(structure): add explicit scene chapter assignment [`#203`](https://github.com/hannasdev/mcp-writing/pull/203)
10
+
11
+ #### [v3.10.0](https://github.com/hannasdev/mcp-writing/compare/v3.9.5...v3.10.0)
12
+
13
+ > 18 May 2026
14
+
15
+ - feat: add read-only structure diagnostics [`#202`](https://github.com/hannasdev/mcp-writing/pull/202)
16
+ - Release 3.10.0 [`d9b1dfb`](https://github.com/hannasdev/mcp-writing/commit/d9b1dfbce6e0391d035725f3c0671253f53ad455)
17
+
7
18
  #### [v3.9.5](https://github.com/hannasdev/mcp-writing/compare/v3.9.4...v3.9.5)
8
19
 
20
+ > 18 May 2026
21
+
9
22
  - refactor(sync): complete target architecture M3 [`#201`](https://github.com/hannasdev/mcp-writing/pull/201)
23
+ - Release 3.9.5 [`9cd308a`](https://github.com/hannasdev/mcp-writing/commit/9cd308aa838a7c3992183ee8c0acce53d61f75cd)
10
24
 
11
25
  #### [v3.9.4](https://github.com/hannasdev/mcp-writing/compare/v3.9.3...v3.9.4)
12
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanna84/mcp-writing",
3
- "version": "3.9.5",
3
+ "version": "3.11.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,77 @@
1
+ import { applySceneStructurePatch } from "./structure-inference.js";
2
+
3
+ export function buildSceneChapterAssignmentPlan(syncDir, filePath, meta = {}, { chapter } = {}) {
4
+ if (chapter === undefined) {
5
+ return {
6
+ ok: false,
7
+ error: {
8
+ code: "VALIDATION_ERROR",
9
+ message: "Provide a canonical chapter or null to clear the scene chapter link.",
10
+ },
11
+ };
12
+ }
13
+
14
+ const currentStructure = applySceneStructurePatch(syncDir, filePath, meta);
15
+ const pathChapter = currentStructure.chapterStructure.chapter ?? null;
16
+ const pathChapterNumber = currentStructure.derived.chapter ?? null;
17
+
18
+ if (chapter === null) {
19
+ if (pathChapter || pathChapterNumber !== null) {
20
+ return {
21
+ ok: false,
22
+ error: {
23
+ code: "VALIDATION_ERROR",
24
+ message: "chapter_id cannot be cleared for a scene whose file path implies a chapter.",
25
+ details: {
26
+ path_chapter: pathChapter?.chapter_id ?? pathChapterNumber,
27
+ },
28
+ },
29
+ };
30
+ }
31
+
32
+ return {
33
+ ok: true,
34
+ meta: applySceneStructurePatch(syncDir, filePath, meta, { chapter: null }).meta,
35
+ assignedChapter: null,
36
+ previousChapterId: meta.chapter_id ?? null,
37
+ };
38
+ }
39
+
40
+ if (pathChapter && pathChapter.chapter_id !== chapter.chapter_id) {
41
+ return {
42
+ ok: false,
43
+ error: {
44
+ code: "VALIDATION_ERROR",
45
+ message: "Cannot assign a scene to a different chapter while its file path implies another canonical chapter.",
46
+ details: {
47
+ requested_chapter_id: chapter.chapter_id,
48
+ requested_chapter: chapter.sort_index,
49
+ path_chapter: pathChapter.chapter_id,
50
+ path_chapter_number: pathChapterNumber,
51
+ },
52
+ },
53
+ };
54
+ }
55
+
56
+ if (!pathChapter && pathChapterNumber !== null && pathChapterNumber !== chapter.sort_index) {
57
+ return {
58
+ ok: false,
59
+ error: {
60
+ code: "VALIDATION_ERROR",
61
+ message: "Cannot assign a scene to a different chapter while its file path implies another compatibility chapter.",
62
+ details: {
63
+ requested_chapter_id: chapter.chapter_id,
64
+ requested_chapter: chapter.sort_index,
65
+ path_chapter: pathChapterNumber,
66
+ },
67
+ },
68
+ };
69
+ }
70
+
71
+ return {
72
+ ok: true,
73
+ meta: applySceneStructurePatch(syncDir, filePath, meta, { chapter }).meta,
74
+ assignedChapter: chapter,
75
+ previousChapterId: meta.chapter_id ?? null,
76
+ };
77
+ }
@@ -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
+ }
@@ -4,6 +4,7 @@ import matter from "gray-matter";
4
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
+ import { buildSceneChapterAssignmentPlan } from "../structure/scene-chapter-assignment.js";
7
8
  import {
8
9
  persistSceneReferenceLink,
9
10
  upsertExplicitReferenceLinkRow,
@@ -510,6 +511,92 @@ export function registerMetadataTools(s, {
510
511
  }
511
512
  );
512
513
 
514
+ // ---- assign_scene_to_chapter --------------------------------------------
515
+ s.tool(
516
+ "assign_scene_to_chapter",
517
+ "Assign a scene to a canonical chapter through the explicit structure workflow. Writes chapter_id plus compatibility chapter/chapter_title fields to the scene sidecar and refreshes the index. Pass chapter_id=null to clear an explicit chapter link on an unchaptered scene. Use list_chapters first to choose a valid canonical chapter_id.",
518
+ {
519
+ scene_id: z.string().describe("The scene_id to assign (e.g. 'sc-011-sebastian')."),
520
+ project_id: z.string().describe("Project the scene belongs to (e.g. 'the-lamb')."),
521
+ chapter_id: z.string().nullable().describe("Canonical chapter identifier. Use list_chapters to find valid values. Pass null to clear an explicit chapter link on an unchaptered scene."),
522
+ },
523
+ async ({ scene_id, project_id, chapter_id }) => {
524
+ if (!SYNC_DIR_WRITABLE) {
525
+ return errorResponse("READ_ONLY", "Cannot assign scene to chapter: sync dir is read-only.");
526
+ }
527
+
528
+ const projectIdCheck = validateProjectId(project_id);
529
+ if (!projectIdCheck.ok) {
530
+ return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
531
+ }
532
+
533
+ const scene = db.prepare(`
534
+ SELECT scene_id, project_id, chapter_id, file_path
535
+ FROM scenes
536
+ WHERE scene_id = ? AND project_id = ?
537
+ `).get(scene_id, project_id);
538
+ if (!scene) {
539
+ return errorResponse("NOT_FOUND", `Scene '${scene_id}' not found in project '${project_id}'.`);
540
+ }
541
+
542
+ let chapter = null;
543
+ if (chapter_id !== null) {
544
+ const resolvedChapterFilter = resolveValidatedChapterFilter(db, {
545
+ projectId: project_id,
546
+ chapterId: chapter_id,
547
+ });
548
+
549
+ if (resolvedChapterFilter.error) {
550
+ return errorResponse(
551
+ resolvedChapterFilter.error.code,
552
+ resolvedChapterFilter.error.message,
553
+ { project_id, chapter_id }
554
+ );
555
+ }
556
+
557
+ chapter = resolvedChapterFilter.chapter;
558
+ if (!chapter) {
559
+ return errorResponse("NOT_FOUND", "Chapter not found for the provided project and identifier.", {
560
+ project_id,
561
+ chapter_id,
562
+ });
563
+ }
564
+ }
565
+
566
+ try {
567
+ const { meta } = readMeta(scene.file_path, SYNC_DIR, { writable: true });
568
+ const plan = buildSceneChapterAssignmentPlan(SYNC_DIR, scene.file_path, meta, { chapter });
569
+ if (!plan.ok) {
570
+ return errorResponse(plan.error.code, plan.error.message, {
571
+ project_id,
572
+ scene_id,
573
+ chapter_id,
574
+ ...(plan.error.details ?? {}),
575
+ });
576
+ }
577
+
578
+ writeMeta(scene.file_path, plan.meta);
579
+
580
+ const { content: prose } = matter(fs.readFileSync(scene.file_path, "utf8"));
581
+ indexSceneFile(db, SYNC_DIR, scene.file_path, plan.meta, prose);
582
+
583
+ return jsonResponse({
584
+ ok: true,
585
+ action: chapter === null ? "cleared" : "assigned",
586
+ scene_id,
587
+ project_id,
588
+ previous_chapter_id: plan.previousChapterId ?? scene.chapter_id ?? null,
589
+ chapter: plan.assignedChapter,
590
+ });
591
+ } catch (err) {
592
+ if (err.code === "ENOENT") {
593
+ return errorResponse("STALE_PATH", `Prose file for scene '${scene_id}' not found at indexed path — the file may have moved. Run sync() to refresh.`, { indexed_path: scene.file_path });
594
+ }
595
+ return errorResponse("IO_ERROR", `Failed to assign scene '${scene_id}' to chapter: ${err.message}`);
596
+ }
597
+ }
598
+ );
599
+
513
600
  // ---- update_scene_metadata -----------------------------------------------
514
601
  s.tool(
515
602
  "update_scene_metadata",
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,11 +69,23 @@ 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." },
75
76
  ],
76
77
  },
78
+ {
79
+ id: "structure_assignment",
80
+ label: "Assign a scene to a chapter",
81
+ use_when: "Use when the user wants to move an unchaptered scene into a canonical chapter, repair an explicit scene chapter link, or clear a scene's explicit chapter assignment.",
82
+ steps: [
83
+ { tool: "find_scenes", note: "Identify the target scene and confirm project_id if the user did not provide both." },
84
+ { tool: "list_chapters", note: "Choose the canonical chapter_id for the target project before assigning." },
85
+ { tool: "assign_scene_to_chapter", note: "Use this named structure workflow for chapter assignment or clearing instead of editing chapter fields through generic metadata updates." },
86
+ { tool: "diagnose_structure", note: "Run when the assignment is part of a drift repair workflow or when folder-derived structure may disagree with the requested link." },
87
+ ],
88
+ },
77
89
  {
78
90
  id: "review_preparation",
79
91
  label: "Prepare material for human review",
@@ -90,6 +102,7 @@ export const WORKFLOW_CATALOGUE = [
90
102
  steps: [
91
103
  { tool: "get_runtime_config", note: "Verify sync dir, writability, and git availability." },
92
104
  { tool: "sync", note: "Index scenes from disk so the main manuscript workflows can operate." },
105
+ { tool: "diagnose_structure", note: "Run after sync when connecting existing projects to inspect structure drift without repairing anything." },
93
106
  ],
94
107
  },
95
108
  {