@hanna84/mcp-writing 3.14.1 → 3.15.1

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.15.1](https://github.com/hannasdev/mcp-writing/compare/v3.15.0...v3.15.1)
8
+
9
+ - chore(security): add eslint security checks [`#211`](https://github.com/hannasdev/mcp-writing/pull/211)
10
+
11
+ #### [v3.15.0](https://github.com/hannasdev/mcp-writing/compare/v3.14.1...v3.15.0)
12
+
13
+ > 20 May 2026
14
+
15
+ - feat(sync): harden structural authority [`#210`](https://github.com/hannasdev/mcp-writing/pull/210)
16
+ - Release 3.15.0 [`8146269`](https://github.com/hannasdev/mcp-writing/commit/81462696b50a33b82bfb37ece8f41b39570b1874)
17
+
7
18
  #### [v3.14.1](https://github.com/hannasdev/mcp-writing/compare/v3.14.0...v3.14.1)
8
19
 
20
+ > 19 May 2026
21
+
9
22
  - ci: optimize validation workflow [`#208`](https://github.com/hannasdev/mcp-writing/pull/208)
23
+ - Release 3.14.1 [`995bb83`](https://github.com/hannasdev/mcp-writing/commit/995bb83941d589061d63ecdae764cf81b1503b41)
10
24
 
11
25
  #### [v3.14.0](https://github.com/hannasdev/mcp-writing/compare/v3.13.1...v3.14.0)
12
26
 
package/README.md CHANGED
@@ -28,7 +28,8 @@ Instead of feeding an entire manuscript to an AI and hoping it fits in the conte
28
28
 
29
29
  **Current status:**
30
30
  - **Core platform complete:** Metadata-first analysis, sidecar-backed metadata maintenance, AI-assisted prose editing with confirmation + git history, review bundles, and Scrivener Direct extraction are all implemented.
31
- - **Active development:** OpenClaw integration, the client-agnostic setup contract, and chapter-structure follow-up work after the first-class chapter/epigraph rollout.
31
+ - **Recently completed:** Structural Authority Hardening tightened remaining structure-authority paths so scene placement and ordering go through explicit structure workflows, ordinary sync reports drift instead of adopting it, and trusted structure exports can be diagnosed and restored explicitly.
32
+ - **Active development:** none selected.
32
33
  - **Deferred backlog:** embeddings search.
33
34
  - **Ideas and open questions:** tracked separately so future exploration does not distort the active roadmap.
34
35
 
@@ -149,7 +150,7 @@ Outcome: subplot structure stays visible and auditable, which reduces dropped th
149
150
  Goal: keep indexes accurate without manually re-tagging everything.
150
151
 
151
152
  1. After rewriting scenes, call `enrich_scene` to re-derive lightweight metadata from current prose.
152
- 2. Use `update_scene_metadata` for intentional editorial fields (for example, beat, POV, timeline position, and tags).
153
+ 2. Use `update_scene_metadata` for intentional editorial fields (for example, beat, POV, status, and tags); use `list_chapters` plus `assign_scene_to_chapter` or `move_scene` for chapter placement and ordering. Numeric chapter filters are compatibility aliases for read scopes, not mutation targets.
153
154
  3. Use `search_metadata` and `find_scenes` to verify scenes are discoverable under the expected filters.
154
155
 
155
156
  Outcome: your AI assistant can reliably find the right scenes without drifting from the manuscript.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanna84/mcp-writing",
3
- "version": "3.14.1",
3
+ "version": "3.15.1",
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",
@@ -110,6 +110,7 @@
110
110
  "@eslint/js": "^10.0.1",
111
111
  "auto-changelog": "^2.5.1",
112
112
  "eslint": "^10.3.0",
113
+ "eslint-plugin-security": "^4.0.0",
113
114
  "globals": "^17.6.0",
114
115
  "release-it": "^20.0.1"
115
116
  },
@@ -202,7 +202,15 @@ export function resolveIndexedChapterForFile(db, {
202
202
  relativePath,
203
203
  meta = {},
204
204
  chapterStructure,
205
+ managedStructure = false,
205
206
  }) {
207
+ const existingScene = meta.scene_id
208
+ ? db.prepare(`
209
+ SELECT chapter_id, chapter, chapter_title
210
+ FROM scenes
211
+ WHERE scene_id = ? AND project_id = ?
212
+ `).get(meta.scene_id, projectId)
213
+ : null;
206
214
  let chapterId = meta.chapter_id ?? chapterStructure.chapter?.chapter_id ?? null;
207
215
  let chapterSortIndex = chapterStructure.chapter?.sort_index ?? meta.chapter ?? null;
208
216
  let chapterTitle = chapterStructure.chapter?.title ?? meta.chapter_title ?? (chapterSortIndex != null ? `Chapter ${chapterSortIndex}` : null);
@@ -213,6 +221,56 @@ export function resolveIndexedChapterForFile(db, {
213
221
  const explicitSceneChapterId = !chapterStructure.isEpigraph ? meta.chapter_id ?? null : null;
214
222
  let explicitSceneCanonicalChapter = null;
215
223
 
224
+ if (managedStructure && !chapterStructure.isEpigraph) {
225
+ let managedWarning = null;
226
+ if (existingScene?.chapter_id) {
227
+ const canonicalChapter = db.prepare(`
228
+ SELECT chapter_id, sort_index, title
229
+ FROM chapters
230
+ WHERE chapter_id = ? AND project_id = ?
231
+ `).get(existingScene.chapter_id, projectId);
232
+ if (canonicalChapter) {
233
+ chapterId = canonicalChapter.chapter_id;
234
+ chapterSortIndex = canonicalChapter.sort_index ?? null;
235
+ chapterTitle = canonicalChapter.title ?? null;
236
+ const observedChapterId = meta.chapter_id ?? chapterStructure.chapter?.chapter_id ?? null;
237
+ const observedSortIndex = chapterStructure.chapter?.sort_index ?? meta.chapter ?? null;
238
+ const observedTitle = chapterStructure.chapter?.title ?? meta.chapter_title ?? null;
239
+ if (
240
+ (observedChapterId && observedChapterId !== chapterId)
241
+ || (observedSortIndex != null && observedSortIndex !== chapterSortIndex)
242
+ || (observedTitle && observedTitle !== chapterTitle)
243
+ ) {
244
+ managedWarning = `Managed structure sync ignored file-derived chapter linkage for scene '${meta.scene_id}': ${relativePath}`;
245
+ }
246
+ } else {
247
+ chapterId = null;
248
+ chapterSortIndex = null;
249
+ chapterTitle = null;
250
+ managedWarning = `Scene references unknown chapter_id '${existingScene.chapter_id}': ${relativePath}`;
251
+ }
252
+ } else {
253
+ const observedChapterId = meta.chapter_id ?? chapterStructure.chapter?.chapter_id ?? null;
254
+ const observedSortIndex = chapterStructure.chapter?.sort_index ?? meta.chapter ?? null;
255
+ const observedTitle = chapterStructure.chapter?.title ?? meta.chapter_title ?? null;
256
+ if (observedChapterId || observedSortIndex != null || observedTitle) {
257
+ managedWarning = `Managed structure sync ignored file-derived chapter linkage for scene '${meta.scene_id}': ${relativePath}`;
258
+ }
259
+ chapterId = null;
260
+ chapterSortIndex = null;
261
+ chapterTitle = null;
262
+ }
263
+
264
+ return {
265
+ chapterId,
266
+ chapterSortIndex,
267
+ chapterTitle,
268
+ chapterSourcePath,
269
+ chapterWarning: managedWarning,
270
+ upsertChapter: null,
271
+ };
272
+ }
273
+
216
274
  if (explicitSceneChapterId && !chapterStructure.chapter) {
217
275
  explicitSceneCanonicalChapter = db.prepare(`
218
276
  SELECT chapter_id, sort_index, title
@@ -1,3 +1,5 @@
1
+ import fs from "node:fs";
2
+
1
3
  export function indexCanonicalEpigraph(db, {
2
4
  projectId,
3
5
  chapterId,
@@ -10,8 +12,116 @@ export function indexCanonicalEpigraph(db, {
10
12
  chapterWarning = null,
11
13
  buildProseChecksum,
12
14
  buildDefaultEpigraphId,
15
+ managedStructure = false,
13
16
  updatedAt = new Date().toISOString(),
14
17
  }) {
18
+ const defaultEpigraphId = chapterId
19
+ ? buildDefaultEpigraphId({ projectId, chapterId })
20
+ : null;
21
+ const requestedEpigraphId = meta.epigraph_id ?? defaultEpigraphId;
22
+ const epigraphChecksum = buildProseChecksum(prose);
23
+
24
+ if (managedStructure) {
25
+ const existingEpigraphByPath = db.prepare(`
26
+ SELECT epigraph_id, chapter_id, file_path, prose_checksum
27
+ FROM epigraphs
28
+ WHERE project_id = ? AND file_path = ?
29
+ LIMIT 1
30
+ `).get(projectId, file);
31
+ const existingEpigraphById = requestedEpigraphId
32
+ ? db.prepare(`
33
+ SELECT e.epigraph_id, e.chapter_id, e.file_path, e.prose_checksum, c.sort_index AS chapter_sort_index
34
+ FROM epigraphs e
35
+ LEFT JOIN chapters c
36
+ ON c.project_id = e.project_id
37
+ AND c.chapter_id = e.chapter_id
38
+ WHERE e.project_id = ? AND e.epigraph_id = ?
39
+ LIMIT 1
40
+ `).get(projectId, requestedEpigraphId)
41
+ : null;
42
+ const existingEpigraphByCompatibleId = existingEpigraphById
43
+ && (
44
+ chapterSortIndex == null
45
+ || existingEpigraphById.chapter_sort_index == null
46
+ || existingEpigraphById.chapter_sort_index === chapterSortIndex
47
+ )
48
+ ? existingEpigraphById
49
+ : null;
50
+ const existingEpigraphByChapter = chapterId
51
+ ? db.prepare(`
52
+ SELECT epigraph_id, chapter_id, file_path, prose_checksum
53
+ FROM epigraphs
54
+ WHERE project_id = ? AND chapter_id = ?
55
+ ORDER BY epigraph_id
56
+ LIMIT 2
57
+ `).all(projectId, chapterId)
58
+ : [];
59
+ const existingEpigraphByChapterSort = chapterSortIndex != null
60
+ ? db.prepare(`
61
+ SELECT e.epigraph_id, e.chapter_id, e.file_path, e.prose_checksum
62
+ FROM epigraphs e
63
+ JOIN chapters c
64
+ ON c.project_id = e.project_id
65
+ AND c.chapter_id = e.chapter_id
66
+ WHERE e.project_id = ? AND c.sort_index = ?
67
+ ORDER BY e.epigraph_id
68
+ LIMIT 2
69
+ `).all(projectId, chapterSortIndex)
70
+ : [];
71
+ const movedCandidate = (candidate) => candidate && (!candidate.file_path || !fs.existsSync(candidate.file_path))
72
+ ? candidate
73
+ : null;
74
+ const existingEpigraph = existingEpigraphByPath
75
+ ?? movedCandidate(existingEpigraphByCompatibleId)
76
+ ?? (existingEpigraphByChapter.length === 1 ? movedCandidate(existingEpigraphByChapter[0]) : null)
77
+ ?? (existingEpigraphByChapterSort.length === 1 ? movedCandidate(existingEpigraphByChapterSort[0]) : null);
78
+
79
+ if (!existingEpigraph) {
80
+ return {
81
+ isStale: 0,
82
+ skippedAsEpigraph: true,
83
+ warning: `Managed structure sync ignored file-derived epigraph linkage: ${relativePath}`,
84
+ };
85
+ }
86
+
87
+ const epigraphIsStale = existingEpigraph.prose_checksum !== null && existingEpigraph.prose_checksum !== epigraphChecksum ? 1 : 0;
88
+ db.prepare(`
89
+ UPDATE epigraphs
90
+ SET body = ?,
91
+ file_path = ?,
92
+ prose_checksum = ?,
93
+ metadata_stale = CASE
94
+ WHEN ? != prose_checksum THEN 1
95
+ ELSE metadata_stale
96
+ END,
97
+ updated_at = ?
98
+ WHERE epigraph_id = ? AND project_id = ?
99
+ `).run(
100
+ prose,
101
+ file,
102
+ epigraphChecksum,
103
+ epigraphChecksum,
104
+ updatedAt,
105
+ existingEpigraph.epigraph_id,
106
+ projectId
107
+ );
108
+
109
+ return {
110
+ isStale: epigraphIsStale,
111
+ skippedAsEpigraph: true,
112
+ epigraphIndexed: true,
113
+ chapterId: existingEpigraph.chapter_id,
114
+ epigraphId: existingEpigraph.epigraph_id,
115
+ warning: existingEpigraphByPath
116
+ ? (requestedEpigraphId && requestedEpigraphId !== existingEpigraph.epigraph_id
117
+ ? `Managed structure sync ignored file-derived epigraph_id '${requestedEpigraphId}': ${relativePath}`
118
+ : chapterId && chapterId !== existingEpigraph.chapter_id
119
+ ? `Managed structure sync ignored file-derived epigraph linkage: ${relativePath}`
120
+ : null)
121
+ : `Managed structure sync preserved canonical epigraph '${existingEpigraph.epigraph_id}' while refreshing moved file path: ${relativePath}`,
122
+ };
123
+ }
124
+
15
125
  const canonicalChapter = chapterId
16
126
  ? db.prepare(`SELECT chapter_id FROM chapters WHERE chapter_id = ? AND project_id = ?`).get(chapterId, projectId)
17
127
  : null;
@@ -26,9 +136,6 @@ export function indexCanonicalEpigraph(db, {
26
136
  return { isStale: 0, skippedAsEpigraph: true, warning: reason };
27
137
  }
28
138
 
29
- const defaultEpigraphId = buildDefaultEpigraphId({ projectId, chapterId });
30
- const requestedEpigraphId = meta.epigraph_id ?? defaultEpigraphId;
31
- const epigraphChecksum = buildProseChecksum(prose);
32
139
  const epigraphById = db.prepare(`
33
140
  SELECT epigraph_id, chapter_id, prose_checksum
34
141
  FROM epigraphs
@@ -2,6 +2,12 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import matter from "gray-matter";
4
4
  import yaml from "js-yaml";
5
+ import {
6
+ buildStructureExport,
7
+ computeStructureChecksum,
8
+ defaultStructureExportFileName,
9
+ STRUCTURE_EXPORT_SCHEMA_VERSION,
10
+ } from "./structure-export.js";
5
11
  import {
6
12
  inferChapterStructureFromPath,
7
13
  normalizeSceneMetaForPath,
@@ -91,6 +97,16 @@ function readIndexedEpigraphRows(db, projectId) {
91
97
  `).all(...scope.params);
92
98
  }
93
99
 
100
+ function readProjectRows(db, projectId) {
101
+ const scope = projectClause(projectId);
102
+ return db.prepare(`
103
+ SELECT project_id
104
+ FROM projects
105
+ WHERE 1 = 1${scope.sql}
106
+ ORDER BY project_id
107
+ `).all(...scope.params);
108
+ }
109
+
94
110
  function diagnoseUnknownChapterLinks(db, diagnostics, projectId) {
95
111
  const sceneScope = projectClause(projectId, "s");
96
112
  const scenes = db.prepare(`
@@ -280,7 +296,20 @@ function diagnoseObservedFiles(syncDir, diagnostics, { scenes, epigraphs }) {
280
296
  }
281
297
 
282
298
  for (const epigraph of epigraphs) {
283
- if (!epigraph.file_path || !fs.existsSync(epigraph.file_path)) continue;
299
+ if (!epigraph.file_path || !fs.existsSync(epigraph.file_path)) {
300
+ addDiagnostic(
301
+ diagnostics,
302
+ "indexed_epigraph_file_missing",
303
+ `Epigraph "${epigraph.epigraph_id}" has an indexed file path that no longer exists.`,
304
+ {
305
+ project_id: epigraph.project_id,
306
+ epigraph_id: epigraph.epigraph_id,
307
+ file_path: epigraph.file_path,
308
+ },
309
+ { nextStep: "Run sync to refresh moved epigraph file paths, then inspect remaining drift." }
310
+ );
311
+ continue;
312
+ }
284
313
  if (!isPathInsideSyncDir(syncDir, epigraph.file_path)) {
285
314
  addDiagnostic(
286
315
  diagnostics,
@@ -334,8 +363,332 @@ function diagnoseObservedFiles(syncDir, diagnostics, { scenes, epigraphs }) {
334
363
  }
335
364
  }
336
365
 
366
+ function resolveStructureExportPath(syncDir, exportDir, projectId) {
367
+ const resolvedSyncDir = path.resolve(syncDir);
368
+ const resolvedExportDir = exportDir
369
+ ? (path.isAbsolute(exportDir) ? path.resolve(exportDir) : path.resolve(resolvedSyncDir, exportDir))
370
+ : path.resolve(resolvedSyncDir, "structure-exports");
371
+ const relativeDir = path.relative(resolvedSyncDir, resolvedExportDir);
372
+ if (relativeDir.startsWith("..") || path.isAbsolute(relativeDir)) {
373
+ throw new Error(`Structure export directory must be inside sync_dir: ${exportDir}`);
374
+ }
375
+ return path.join(resolvedExportDir, defaultStructureExportFileName(projectId));
376
+ }
377
+
378
+ function readStructureExportFile(filePath) {
379
+ try {
380
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
381
+ } catch (error) {
382
+ return {
383
+ error: error instanceof Error ? error : new Error("Could not read structure export."),
384
+ };
385
+ }
386
+ }
387
+
388
+ function diagnoseStructureExportFileKind(diagnostics, {
389
+ projectId,
390
+ exportPath,
391
+ }) {
392
+ let exportStat;
393
+ try {
394
+ exportStat = fs.lstatSync(exportPath);
395
+ } catch (error) {
396
+ if (error?.code !== "ENOENT") throw error;
397
+ addDiagnostic(
398
+ diagnostics,
399
+ "structure_export_missing",
400
+ `Project "${projectId}" does not have a generated structure export.`,
401
+ {
402
+ project_id: projectId,
403
+ export_path: exportPath,
404
+ },
405
+ {
406
+ severity: "info",
407
+ nextStep: "Run export_structure_snapshot before relying on export-based recovery.",
408
+ }
409
+ );
410
+ return "missing";
411
+ }
412
+
413
+ if (exportStat.isSymbolicLink()) {
414
+ addDiagnostic(
415
+ diagnostics,
416
+ "structure_export_symlink",
417
+ `Structure export for project "${projectId}" is a symlink, which is not trusted diagnostics input.`,
418
+ {
419
+ project_id: projectId,
420
+ export_path: exportPath,
421
+ },
422
+ {
423
+ nextStep: "Use a regular generated structure export file under WRITING_SYNC_DIR.",
424
+ }
425
+ );
426
+ return "symlink";
427
+ }
428
+
429
+ if (!exportStat.isFile()) {
430
+ addDiagnostic(
431
+ diagnostics,
432
+ "structure_export_not_regular",
433
+ `Structure export for project "${projectId}" is not a regular file.`,
434
+ {
435
+ project_id: projectId,
436
+ export_path: exportPath,
437
+ },
438
+ {
439
+ nextStep: "Regenerate the export with export_structure_snapshot before using it for recovery.",
440
+ }
441
+ );
442
+ return "not_regular";
443
+ }
444
+
445
+ return "regular";
446
+ }
447
+
448
+ function diagnoseStructureExports(db, diagnostics, {
449
+ syncDir,
450
+ exportDir,
451
+ projectId,
452
+ }) {
453
+ if (!syncDir) return [];
454
+
455
+ const exportChecks = [];
456
+ const projects = readProjectRows(db, projectId);
457
+ for (const project of projects) {
458
+ const expectedProjectId = project.project_id;
459
+ let exportPath;
460
+ try {
461
+ exportPath = resolveStructureExportPath(syncDir, exportDir, expectedProjectId);
462
+ } catch {
463
+ addDiagnostic(
464
+ diagnostics,
465
+ "structure_export_invalid_location",
466
+ `Structure export location for project "${expectedProjectId}" is outside the active sync root.`,
467
+ {
468
+ project_id: expectedProjectId,
469
+ export_dir: exportDir,
470
+ sync_dir: syncDir,
471
+ },
472
+ {
473
+ nextStep: "Use an export directory inside WRITING_SYNC_DIR before trusting generated structure exports.",
474
+ }
475
+ );
476
+ exportChecks.push({
477
+ project_id: expectedProjectId,
478
+ export_path: null,
479
+ trusted: false,
480
+ status: "invalid_location",
481
+ });
482
+ continue;
483
+ }
484
+
485
+ const exportFileKind = diagnoseStructureExportFileKind(diagnostics, {
486
+ projectId: expectedProjectId,
487
+ exportPath,
488
+ });
489
+ if (exportFileKind !== "regular") {
490
+ exportChecks.push({
491
+ project_id: expectedProjectId,
492
+ export_path: exportPath,
493
+ trusted: false,
494
+ status: exportFileKind,
495
+ });
496
+ continue;
497
+ }
498
+
499
+ const parsed = readStructureExportFile(exportPath);
500
+ if (parsed.error) {
501
+ addDiagnostic(
502
+ diagnostics,
503
+ "structure_export_unreadable",
504
+ `Structure export for project "${expectedProjectId}" could not be read as JSON.`,
505
+ {
506
+ project_id: expectedProjectId,
507
+ export_path: exportPath,
508
+ error: parsed.error.message,
509
+ },
510
+ {
511
+ nextStep: "Regenerate the export with export_structure_snapshot before using it for recovery.",
512
+ }
513
+ );
514
+ exportChecks.push({
515
+ project_id: expectedProjectId,
516
+ export_path: exportPath,
517
+ trusted: false,
518
+ status: "unreadable",
519
+ });
520
+ continue;
521
+ }
522
+
523
+ const exportedProjectId = parsed.project?.project_id ?? parsed.export?.project_id ?? null;
524
+ if (exportedProjectId !== expectedProjectId) {
525
+ addDiagnostic(
526
+ diagnostics,
527
+ "structure_export_project_mismatch",
528
+ `Structure export for project "${expectedProjectId}" belongs to project "${exportedProjectId ?? "unknown"}".`,
529
+ {
530
+ project_id: expectedProjectId,
531
+ export_path: exportPath,
532
+ exported_project_id: exportedProjectId,
533
+ },
534
+ {
535
+ nextStep: "Regenerate the export for this project before using it for recovery.",
536
+ }
537
+ );
538
+ exportChecks.push({
539
+ project_id: expectedProjectId,
540
+ export_path: exportPath,
541
+ trusted: false,
542
+ status: "wrong_project",
543
+ });
544
+ continue;
545
+ }
546
+
547
+ const exportedSchemaVersion = parsed.export?.schema_version ?? null;
548
+ if (exportedSchemaVersion !== STRUCTURE_EXPORT_SCHEMA_VERSION) {
549
+ addDiagnostic(
550
+ diagnostics,
551
+ "structure_export_incompatible_schema",
552
+ `Structure export for project "${expectedProjectId}" has schema version "${exportedSchemaVersion ?? "unknown"}"; expected "${STRUCTURE_EXPORT_SCHEMA_VERSION}".`,
553
+ {
554
+ project_id: expectedProjectId,
555
+ export_path: exportPath,
556
+ exported_schema_version: exportedSchemaVersion,
557
+ expected_schema_version: STRUCTURE_EXPORT_SCHEMA_VERSION,
558
+ },
559
+ {
560
+ nextStep: "Regenerate the export with the current server before using it for recovery.",
561
+ }
562
+ );
563
+ exportChecks.push({
564
+ project_id: expectedProjectId,
565
+ export_path: exportPath,
566
+ trusted: false,
567
+ status: "incompatible_schema",
568
+ });
569
+ continue;
570
+ }
571
+
572
+ let built;
573
+ try {
574
+ built = buildStructureExport(db, {
575
+ projectId: expectedProjectId,
576
+ syncDir,
577
+ });
578
+ } catch (error) {
579
+ addDiagnostic(
580
+ diagnostics,
581
+ "structure_export_current_snapshot_failed",
582
+ `Could not build current structure snapshot for project "${expectedProjectId}".`,
583
+ {
584
+ project_id: expectedProjectId,
585
+ export_path: exportPath,
586
+ error_code: "CURRENT_SNAPSHOT_FAILED",
587
+ error_message: error instanceof Error ? error.message : String(error),
588
+ },
589
+ {
590
+ nextStep: "Repair the canonical project record before trusting structure exports.",
591
+ }
592
+ );
593
+ exportChecks.push({
594
+ project_id: expectedProjectId,
595
+ export_path: exportPath,
596
+ trusted: false,
597
+ status: "current_snapshot_failed",
598
+ });
599
+ continue;
600
+ }
601
+ if (!built.ok) {
602
+ addDiagnostic(
603
+ diagnostics,
604
+ "structure_export_current_snapshot_failed",
605
+ `Could not build current structure snapshot for project "${expectedProjectId}".`,
606
+ {
607
+ project_id: expectedProjectId,
608
+ export_path: exportPath,
609
+ error_code: built.error.code,
610
+ error_message: built.error.message,
611
+ },
612
+ {
613
+ nextStep: "Repair the canonical project record before trusting structure exports.",
614
+ }
615
+ );
616
+ exportChecks.push({
617
+ project_id: expectedProjectId,
618
+ export_path: exportPath,
619
+ trusted: false,
620
+ status: "current_snapshot_failed",
621
+ });
622
+ continue;
623
+ }
624
+
625
+ const exportedChecksum = parsed.export?.structure_checksum ?? null;
626
+ const computedExportChecksum = computeStructureChecksum(parsed);
627
+ if (!exportedChecksum || exportedChecksum !== computedExportChecksum) {
628
+ addDiagnostic(
629
+ diagnostics,
630
+ "structure_export_checksum_mismatch",
631
+ `Structure export for project "${expectedProjectId}" does not match its embedded checksum.`,
632
+ {
633
+ project_id: expectedProjectId,
634
+ export_path: exportPath,
635
+ exported_checksum: exportedChecksum,
636
+ computed_checksum: computedExportChecksum,
637
+ },
638
+ {
639
+ nextStep: "Regenerate the export with export_structure_snapshot before using it for recovery.",
640
+ }
641
+ );
642
+ exportChecks.push({
643
+ project_id: expectedProjectId,
644
+ export_path: exportPath,
645
+ trusted: false,
646
+ status: "checksum_mismatch",
647
+ });
648
+ continue;
649
+ }
650
+
651
+ const currentChecksum = built.snapshot.export.structure_checksum;
652
+ if (exportedChecksum !== currentChecksum) {
653
+ addDiagnostic(
654
+ diagnostics,
655
+ "structure_export_stale",
656
+ `Structure export for project "${expectedProjectId}" is stale relative to current SQLite canonical state.`,
657
+ {
658
+ project_id: expectedProjectId,
659
+ export_path: exportPath,
660
+ exported_checksum: exportedChecksum,
661
+ current_checksum: currentChecksum,
662
+ },
663
+ {
664
+ nextStep: "Regenerate the export with export_structure_snapshot, then review the Git diff.",
665
+ }
666
+ );
667
+ exportChecks.push({
668
+ project_id: expectedProjectId,
669
+ export_path: exportPath,
670
+ trusted: false,
671
+ status: "stale",
672
+ });
673
+ continue;
674
+ }
675
+
676
+ exportChecks.push({
677
+ project_id: expectedProjectId,
678
+ export_path: exportPath,
679
+ trusted: true,
680
+ status: "current",
681
+ schema_version: exportedSchemaVersion,
682
+ structure_checksum: currentChecksum,
683
+ });
684
+ }
685
+
686
+ return exportChecks;
687
+ }
688
+
337
689
  export function runStructureDiagnostics(db, {
338
690
  syncDir,
691
+ structureExportDir = null,
339
692
  projectId = null,
340
693
  } = {}) {
341
694
  const diagnostics = [];
@@ -349,6 +702,12 @@ export function runStructureDiagnostics(db, {
349
702
  diagnoseObservedFiles(syncDir, diagnostics, { scenes, epigraphs });
350
703
  }
351
704
 
705
+ const structureExports = diagnoseStructureExports(db, diagnostics, {
706
+ syncDir,
707
+ exportDir: structureExportDir,
708
+ projectId,
709
+ });
710
+
352
711
  diagnostics.sort((a, b) => {
353
712
  const projectCompare = String(a.details.project_id ?? "").localeCompare(String(b.details.project_id ?? ""));
354
713
  if (projectCompare) return projectCompare;
@@ -363,6 +722,7 @@ export function runStructureDiagnostics(db, {
363
722
  project_id: projectId,
364
723
  scenes: scenes.length,
365
724
  epigraphs: epigraphs.length,
725
+ structure_exports: structureExports,
366
726
  },
367
727
  summary: {
368
728
  total: diagnostics.length,
@@ -45,6 +45,22 @@ function sha256(value) {
45
45
  return crypto.createHash("sha256").update(value).digest("hex");
46
46
  }
47
47
 
48
+ export function computeStructureChecksum(snapshot) {
49
+ const {
50
+ export: exportMetadata = {},
51
+ ...rest
52
+ } = snapshot ?? {};
53
+ const {
54
+ structure_checksum: _structureChecksum,
55
+ ...checksumExportMetadata
56
+ } = exportMetadata;
57
+
58
+ return sha256(stableStringify({
59
+ export: checksumExportMetadata,
60
+ ...rest,
61
+ }, 0));
62
+ }
63
+
48
64
  export function defaultStructureExportFileName(projectId) {
49
65
  const slug = String(projectId ?? "project")
50
66
  .toLowerCase()
@@ -173,7 +189,7 @@ export function buildStructureExport(db, { projectId, syncDir }) {
173
189
  epigraphs,
174
190
  };
175
191
 
176
- const structureChecksum = sha256(stableStringify(baseSnapshot, 0));
192
+ const structureChecksum = computeStructureChecksum(baseSnapshot);
177
193
  return {
178
194
  ok: true,
179
195
  snapshot: {