@hanna84/mcp-writing 1.11.1 → 1.11.3

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,11 +4,31 @@ 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
+ #### [v1.11.3](https://github.com/hannasdev/mcp-writing.git
8
+ /compare/v1.11.2...v1.11.3)
9
+
10
+ - docs: reorganize AI-first documentation into scoped instructions [`#62`](https://github.com/hannasdev/mcp-writing.git
11
+ /pull/62)
12
+
13
+ #### [v1.11.2](https://github.com/hannasdev/mcp-writing.git
14
+ /compare/v1.11.1...v1.11.2)
15
+
16
+ > 23 April 2026
17
+
18
+ - Scrivener beta: opt-in chapter organization + raw keyword tags [`#60`](https://github.com/hannasdev/mcp-writing.git
19
+ /pull/60)
20
+ - Release 1.11.2 [`3d8dfe5`](https://github.com/hannasdev/mcp-writing.git
21
+ /commit/3d8dfe5c75ee29ab1dfe2f242c1ea837d810995f)
22
+
7
23
  #### [v1.11.1](https://github.com/hannasdev/mcp-writing.git
8
24
  /compare/v1.11.0...v1.11.1)
9
25
 
26
+ > 22 April 2026
27
+
10
28
  - Implement graceful batch cancellation and complete Phase D coverage [`#59`](https://github.com/hannasdev/mcp-writing.git
11
29
  /pull/59)
30
+ - Release 1.11.1 [`85bb5c6`](https://github.com/hannasdev/mcp-writing.git
31
+ /commit/85bb5c6c3643256eb6866c7b75cc3e3f87ce1412)
12
32
 
13
33
  #### [v1.11.0](https://github.com/hannasdev/mcp-writing.git
14
34
  /compare/v1.10.0...v1.11.0)
package/db.js CHANGED
@@ -18,6 +18,7 @@ export const SCHEMA = `
18
18
  title TEXT,
19
19
  part INTEGER,
20
20
  chapter INTEGER,
21
+ chapter_title TEXT,
21
22
  pov TEXT,
22
23
  logline TEXT,
23
24
  scene_change TEXT,
@@ -124,6 +125,11 @@ export function openDb(dbPath) {
124
125
  const db = new DatabaseSync(dbPath);
125
126
  db.exec(SCHEMA);
126
127
 
128
+ const sceneColumns = db.prepare(`PRAGMA table_info(scenes)`).all();
129
+ if (!sceneColumns.some(column => column.name === "chapter_title")) {
130
+ db.exec(`ALTER TABLE scenes ADD COLUMN chapter_title TEXT;`);
131
+ }
132
+
127
133
  // Rebuild legacy FTS table if it predates keyword indexing.
128
134
  // Preserve existing indexed rows so metadata search remains available
129
135
  // even before the next sync pass repopulates from source files.
package/importer.js CHANGED
@@ -107,20 +107,35 @@ function loadYamlFile(filePath) {
107
107
  }
108
108
  }
109
109
 
110
+ function walkSidecarFiles(dir, fileList = []) {
111
+ if (!fs.existsSync(dir)) return fileList;
112
+
113
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
114
+ const full = path.join(dir, entry.name);
115
+ if (entry.isDirectory()) {
116
+ // Skip nested mirror trees under scenes/ (e.g. scenes/projects/... or scenes/universes/...)
117
+ // to avoid accidental reconciliation against duplicated sidecars.
118
+ if (/^(projects|universes)$/i.test(entry.name)) continue;
119
+ walkSidecarFiles(full, fileList);
120
+ } else if (entry.isFile() && entry.name.endsWith(".meta.yaml")) {
121
+ fileList.push(full);
122
+ }
123
+ }
124
+
125
+ return fileList;
126
+ }
127
+
110
128
  function buildExistingSceneIndex(dir) {
111
129
  const byBinderId = new Map();
112
130
  if (!fs.existsSync(dir)) return byBinderId;
113
131
 
114
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
115
- if (!entry.isFile() || !entry.name.endsWith(".meta.yaml")) continue;
116
-
117
- const sidecarPath = path.join(dir, entry.name);
132
+ for (const sidecarPath of walkSidecarFiles(dir)) {
118
133
  const proseCandidates = [
119
134
  sidecarPath.replace(/\.meta\.yaml$/, ".txt"),
120
135
  sidecarPath.replace(/\.meta\.yaml$/, ".md"),
121
136
  ];
122
137
  const prosePath = proseCandidates.find(candidate => fs.existsSync(candidate)) ?? null;
123
- const proseName = prosePath ? path.basename(prosePath) : entry.name.replace(/\.meta\.yaml$/, ".txt");
138
+ const proseName = prosePath ? path.basename(prosePath) : path.basename(sidecarPath).replace(/\.meta\.yaml$/, ".txt");
124
139
  const parsedName = parseFilename(proseName);
125
140
  const meta = loadYamlFile(sidecarPath);
126
141
  const binderId = meta.external_source === "scrivener" && meta.external_id
@@ -342,7 +357,12 @@ export function importScrivenerSync({
342
357
  const title = cleanTitle(rawTitle);
343
358
  const existingScene = existingScenes.get(String(binderId)) ?? null;
344
359
  const sceneId = existingScene?.meta?.scene_id ?? makeSceneId(binderId, title);
345
- const destFile = path.join(scenesDir, `${seq.toString().padStart(3, "0")} ${rawTitle} [${binderId}].${ext}`);
360
+ const targetDir = existingScene?.prosePath
361
+ ? path.dirname(existingScene.prosePath)
362
+ : existingScene?.sidecarPath
363
+ ? path.dirname(existingScene.sidecarPath)
364
+ : scenesDir;
365
+ const destFile = path.join(targetDir, `${seq.toString().padStart(3, "0")} ${rawTitle} [${binderId}].${ext}`);
346
366
  const sidecar = destFile.replace(/\.(txt|md)$/, ".meta.yaml");
347
367
 
348
368
  const meta = {
@@ -366,6 +386,7 @@ export function importScrivenerSync({
366
386
  }
367
387
  logger(` scene_id: ${sceneId}, beat: ${beatCarry ?? "(none)"}`);
368
388
  } else {
389
+ fs.mkdirSync(path.dirname(destFile), { recursive: true });
369
390
  fs.copyFileSync(file, destFile);
370
391
  fs.writeFileSync(sidecar, yaml.dump(meta, { lineWidth: 120 }), "utf8");
371
392
 
package/index.js CHANGED
@@ -775,7 +775,7 @@ function createMcpServer() {
775
775
  // ---- import_scrivener_sync ----------------------------------------------
776
776
  s.tool(
777
777
  "import_scrivener_sync",
778
- "Import Scrivener External Folder Sync Draft files into this server's WRITING_SYNC_DIR by generating scene sidecars and reconciling by Scrivener binder ID. Use this for first-time setup before sync().",
778
+ "[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().",
779
779
  {
780
780
  source_dir: z.string().describe("Path to Scrivener external sync folder (the folder that contains Draft/, or Draft/ itself)."),
781
781
  project_id: z.string().optional().describe("Project ID override (e.g. 'the-lamb'). Defaults to a slug derived from WRITING_SYNC_DIR."),
@@ -897,15 +897,16 @@ function createMcpServer() {
897
897
  // ---- merge_scrivener_project_beta --------------------------------------
898
898
  s.tool(
899
899
  "merge_scrivener_project_beta",
900
- "[BETA] Merge metadata directly from a Scrivener .scriv project into existing scene sidecars. This path is opt-in and may be sensitive to Scrivener internal format changes. Requires scenes sidecars to already exist (for example, from import_scrivener_sync).",
900
+ "[BETA] Merge metadata directly from a Scrivener .scriv project into existing scene sidecars. This path is opt-in, requires sidecars to already exist (for example, from import_scrivener_sync), and may be sensitive to Scrivener internal format changes.",
901
901
  {
902
902
  source_project_dir: z.string().describe("Path to a Scrivener .scriv bundle directory."),
903
903
  project_id: z.string().optional().describe("Project ID containing existing sidecars (e.g. 'the-lamb' or 'universe-1/book-1-the-lamb'). Defaults to a slug derived from WRITING_SYNC_DIR."),
904
904
  scenes_dir: z.string().optional().describe("Absolute path to the scenes directory containing .meta.yaml sidecars. Overrides the path derived from project_id. Use this for non-standard sync layouts."),
905
905
  dry_run: z.boolean().optional().describe("If true (default), reports planned merges without writing files."),
906
906
  auto_sync: z.boolean().optional().describe("If true (default), runs sync() after a non-dry-run merge."),
907
+ organize_by_chapters: z.boolean().optional().describe("If true (default false), relocate scene files into chapter-based folder hierarchies (e.g., chapter-7-harbor/). Chapter metadata is always extracted to sidecars regardless of this flag."),
907
908
  },
908
- async ({ source_project_dir, project_id, scenes_dir, dry_run = true, auto_sync = true }) => {
909
+ async ({ source_project_dir, project_id, scenes_dir, dry_run = true, auto_sync = true, organize_by_chapters = false }) => {
909
910
  if (project_id !== undefined) {
910
911
  const projectIdCheck = validateProjectId(project_id);
911
912
  if (!projectIdCheck.ok) {
@@ -943,6 +944,7 @@ function createMcpServer() {
943
944
  projectId: project_id,
944
945
  scenesDir: normalizedScenesDir,
945
946
  dryRun: Boolean(dry_run),
947
+ organizeByChapters: Boolean(organize_by_chapters),
946
948
  });
947
949
  } catch (error) {
948
950
  return errorResponse(
@@ -973,10 +975,14 @@ function createMcpServer() {
973
975
  dry_run: mergeResult.dryRun,
974
976
  sidecar_files: mergeResult.sidecarFiles,
975
977
  updated: mergeResult.updated,
978
+ relocated: mergeResult.relocated,
976
979
  unchanged: mergeResult.unchanged,
977
980
  no_data: mergeResult.noData,
978
981
  field_add_counts: mergeResult.fieldAddCounts,
979
982
  preview_changes: mergeResult.previewChanges,
983
+ warnings: mergeResult.warnings,
984
+ warnings_truncated: mergeResult.warningsTruncated,
985
+ warning_summary: mergeResult.warningSummary,
980
986
  stats: {
981
987
  sync_map_entries: mergeResult.stats.syncMapEntries,
982
988
  keyword_map_entries: mergeResult.stats.keywordMapEntries,
@@ -1009,7 +1015,7 @@ function createMcpServer() {
1009
1015
  // ---- async import/merge jobs --------------------------------------------
1010
1016
  s.tool(
1011
1017
  "import_scrivener_sync_async",
1012
- "Start an asynchronous Scrivener External Folder Sync import job. Returns immediately with a job_id to poll via get_async_job_status.",
1018
+ "[STABLE] Start an asynchronous Scrivener External Folder Sync import job. This is the recommended default import path when the sync tree is large. Returns immediately with a job_id to poll via get_async_job_status.",
1013
1019
  {
1014
1020
  source_dir: z.string().describe("Path to Scrivener external sync folder (the folder that contains Draft/, or Draft/ itself)."),
1015
1021
  project_id: z.string().optional().describe("Project ID override (e.g. 'the-lamb' or 'universe-1/book-1-the-lamb')."),
@@ -1089,15 +1095,16 @@ function createMcpServer() {
1089
1095
 
1090
1096
  s.tool(
1091
1097
  "merge_scrivener_project_beta_async",
1092
- "Start an asynchronous beta Scrivener metadata merge job. Returns immediately with a job_id to poll via get_async_job_status.",
1098
+ "[BETA] Start an asynchronous Scrivener metadata merge job from a `.scriv` project into existing scene sidecars. Use this only after the stable import path has created sidecars. Returns immediately with a job_id to poll via get_async_job_status.",
1093
1099
  {
1094
1100
  source_project_dir: z.string().describe("Path to a Scrivener .scriv bundle directory."),
1095
1101
  project_id: z.string().optional().describe("Project ID containing existing sidecars (e.g. 'the-lamb' or 'universe-1/book-1-the-lamb')."),
1096
1102
  scenes_dir: z.string().optional().describe("Absolute path to the scenes directory containing .meta.yaml sidecars. Overrides the path derived from project_id."),
1097
1103
  dry_run: z.boolean().optional().describe("If true (default), reports planned merges without writing files."),
1098
1104
  auto_sync: z.boolean().optional().describe("If true, runs sync() after a non-dry-run async merge finishes."),
1105
+ organize_by_chapters: z.boolean().optional().describe("If true (default false), relocate scene files into chapter-based folder hierarchies. Chapter metadata is always extracted to sidecars."),
1099
1106
  },
1100
- async ({ source_project_dir, project_id, scenes_dir, dry_run = true, auto_sync = false }) => {
1107
+ async ({ source_project_dir, project_id, scenes_dir, dry_run = true, auto_sync = false, organize_by_chapters = false }) => {
1101
1108
  if (project_id !== undefined) {
1102
1109
  const projectIdCheck = validateProjectId(project_id);
1103
1110
  if (!projectIdCheck.ok) {
@@ -1136,6 +1143,7 @@ function createMcpServer() {
1136
1143
  project_id,
1137
1144
  scenes_dir: normalizedScenesDir,
1138
1145
  dry_run: Boolean(dry_run),
1146
+ organize_by_chapters: Boolean(organize_by_chapters),
1139
1147
  },
1140
1148
  context: {
1141
1149
  sync_dir: SYNC_DIR,
@@ -1423,7 +1431,7 @@ function createMcpServer() {
1423
1431
  },
1424
1432
  async ({ project_id, character, beat, tag, part, chapter, pov, page, page_size }) => {
1425
1433
  let query = `
1426
- SELECT DISTINCT s.scene_id, s.project_id, s.title, s.part, s.chapter, s.pov,
1434
+ SELECT DISTINCT s.scene_id, s.project_id, s.title, s.part, s.chapter, s.chapter_title, s.pov,
1427
1435
  s.logline, s.scene_change, s.causality, s.stakes, s.scene_functions,
1428
1436
  s.save_the_cat_beat, s.timeline_position, s.story_time,
1429
1437
  s.word_count, s.metadata_stale
@@ -1581,7 +1589,7 @@ function createMcpServer() {
1581
1589
  },
1582
1590
  async ({ character_id, project_id, page, page_size }) => {
1583
1591
  let query = `
1584
- SELECT s.scene_id, s.project_id, s.part, s.chapter, s.title, s.logline,
1592
+ SELECT s.scene_id, s.project_id, s.part, s.chapter, s.chapter_title, s.title, s.logline,
1585
1593
  s.scene_change, s.causality, s.stakes, s.scene_functions,
1586
1594
  s.save_the_cat_beat, s.timeline_position, s.story_time, s.pov, s.metadata_stale
1587
1595
  FROM scenes s
@@ -1859,7 +1867,7 @@ function createMcpServer() {
1859
1867
 
1860
1868
  if (!shouldPaginate) {
1861
1869
  const rows = db.prepare(`
1862
- SELECT f.scene_id, f.project_id, s.title, s.logline, s.part, s.chapter, s.metadata_stale
1870
+ SELECT f.scene_id, f.project_id, s.title, s.logline, s.part, s.chapter, s.chapter_title, s.metadata_stale
1863
1871
  FROM scenes_fts f
1864
1872
  JOIN scenes s ON s.scene_id = f.scene_id AND s.project_id = f.project_id
1865
1873
  WHERE scenes_fts MATCH ?
@@ -1876,7 +1884,7 @@ function createMcpServer() {
1876
1884
  const offset = (normalizedPage - 1) * safePageSize;
1877
1885
 
1878
1886
  const rows = db.prepare(`
1879
- SELECT f.scene_id, f.project_id, s.title, s.logline, s.part, s.chapter, s.metadata_stale
1887
+ SELECT f.scene_id, f.project_id, s.title, s.logline, s.part, s.chapter, s.chapter_title, s.metadata_stale
1880
1888
  FROM scenes_fts f
1881
1889
  JOIN scenes s ON s.scene_id = f.scene_id AND s.project_id = f.project_id
1882
1890
  WHERE scenes_fts MATCH ?
@@ -1942,7 +1950,7 @@ function createMcpServer() {
1942
1950
  }
1943
1951
 
1944
1952
  const rows = db.prepare(`
1945
- SELECT s.scene_id, s.project_id, s.part, s.chapter, s.title, s.logline,
1953
+ SELECT s.scene_id, s.project_id, s.part, s.chapter, s.chapter_title, s.title, s.logline,
1946
1954
  st.beat AS thread_beat, s.timeline_position, s.story_time, s.metadata_stale
1947
1955
  FROM scenes s
1948
1956
  JOIN scene_threads st ON st.scene_id = s.scene_id AND st.thread_id = ?
@@ -2283,7 +2291,7 @@ function createMcpServer() {
2283
2291
  let query = `
2284
2292
  SELECT r.from_character, r.to_character, r.relationship_type, r.strength,
2285
2293
  r.scene_id, r.note,
2286
- s.part, s.chapter, s.timeline_position, s.title AS scene_title
2294
+ s.part, s.chapter, s.chapter_title, s.timeline_position, s.title AS scene_title
2287
2295
  FROM character_relationships r
2288
2296
  LEFT JOIN scenes s ON s.scene_id = r.scene_id
2289
2297
  WHERE r.from_character = ? AND r.to_character = ?
package/metadata-lint.js CHANGED
@@ -32,6 +32,7 @@ const sceneSchema = z.object({
32
32
  title: z.string().min(1).optional(),
33
33
  part: z.number().int().positive().optional(),
34
34
  chapter: z.number().int().positive().optional(),
35
+ chapter_title: z.string().min(1).optional(),
35
36
  pov: z.string().min(1).optional(),
36
37
  logline: z.string().min(1).optional(),
37
38
  save_the_cat_beat: z.string().min(1).optional(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanna84/mcp-writing",
3
- "version": "1.11.1",
3
+ "version": "1.11.3",
4
4
  "description": "MCP service for AI-assisted reasoning and editing on long-form fiction projects",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -61,10 +61,14 @@ function normalizeMergeResult(mergeResult) {
61
61
  dry_run: mergeResult.dryRun,
62
62
  sidecar_files: mergeResult.sidecarFiles,
63
63
  updated: mergeResult.updated,
64
+ relocated: mergeResult.relocated,
64
65
  unchanged: mergeResult.unchanged,
65
66
  no_data: mergeResult.noData,
66
67
  field_add_counts: mergeResult.fieldAddCounts,
67
68
  preview_changes: mergeResult.previewChanges,
69
+ warnings: mergeResult.warnings,
70
+ warnings_truncated: mergeResult.warningsTruncated,
71
+ warning_summary: mergeResult.warningSummary,
68
72
  stats: {
69
73
  sync_map_entries: mergeResult.stats.syncMapEntries,
70
74
  keyword_map_entries: mergeResult.stats.keywordMapEntries,
@@ -121,6 +125,7 @@ async function main() {
121
125
  projectId: request.args?.project_id,
122
126
  scenesDir: request.args?.scenes_dir,
123
127
  dryRun: Boolean(request.args?.dry_run),
128
+ organizeByChapters: Boolean(request.args?.organize_by_chapters),
124
129
  });
125
130
  writeResult(resultPath, normalizeMergeResult(result));
126
131
  return;
@@ -425,9 +425,9 @@ function generateMarkdown(tools) {
425
425
  lines.push('_No parameters._');
426
426
  } else {
427
427
  lines.push('| Parameter | Type | Required | Description |');
428
- lines.push('|-----------|------|:--------:|-------------|');
428
+ lines.push('| --- | --- | :---: | --- |');
429
429
  for (const p of tool.params) {
430
- const req = p.optional ? '' : '';
430
+ const req = p.optional ? 'No' : 'Yes';
431
431
  const desc = p.description.replace(/\|/g, '\\|'); // escape pipes
432
432
  lines.push(`| \`${p.name}\` | \`${p.type}\` | ${req} | ${desc} |`);
433
433
  }
@@ -11,6 +11,7 @@
11
11
  * Options:
12
12
  * --project <id> Project ID (default: derived from mcp-sync-dir name)
13
13
  * --dry-run Show what would change without writing anything
14
+ * --organize-by-chapters Relocate scene files into part/chapter folders
14
15
  *
15
16
  * What it merges into scene sidecars:
16
17
  * synopsis - from Files/Data/<UUID>/synopsis.txt
@@ -34,13 +35,14 @@ import { mergeScrivenerProjectMetadata } from "../scrivener-direct.js";
34
35
  // ---------------------------------------------------------------------------
35
36
  const args = process.argv.slice(2);
36
37
  if (args.length < 2 || args[0] === "--help") {
37
- console.log("Usage: node scripts/merge-scrivx.js <path-to.scriv> <mcp-sync-dir> [--project <id>] [--dry-run]");
38
+ console.log("Usage: node scripts/merge-scrivx.js <path-to.scriv> <mcp-sync-dir> [--project <id>] [--dry-run] [--organize-by-chapters]");
38
39
  process.exit(args[0] === "--help" ? 0 : 1);
39
40
  }
40
41
 
41
42
  const scrivPath = path.resolve(args[0]);
42
43
  const mcpSyncDir = path.resolve(args[1]);
43
44
  const dryRun = args.includes("--dry-run");
45
+ const organizeByChapters = args.includes("--organize-by-chapters");
44
46
  const projectIdx = args.indexOf("--project");
45
47
  const projectId = projectIdx !== -1
46
48
  ? args[projectIdx + 1]
@@ -58,6 +60,7 @@ try {
58
60
  mcpSyncDir,
59
61
  projectId,
60
62
  dryRun,
63
+ organizeByChapters,
61
64
  logger: line => console.log(line),
62
65
  });
63
66
  } catch (err) {
@@ -23,6 +23,9 @@ function children(el, tag) {
23
23
 
24
24
  function walkYamls(dir, list = []) {
25
25
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
26
+ if (entry.isDirectory() && /^(projects|universes)$/i.test(entry.name)) {
27
+ continue;
28
+ }
26
29
  const full = path.join(dir, entry.name);
27
30
  if (entry.isDirectory()) walkYamls(full, list);
28
31
  else if (entry.name.endsWith(".meta.yaml")) list.push(full);
@@ -34,29 +37,170 @@ function isPlainObject(value) {
34
37
  return value !== null && typeof value === "object" && !Array.isArray(value);
35
38
  }
36
39
 
40
+ function slugifyPathSegment(value) {
41
+ return String(value ?? "")
42
+ .normalize("NFKD")
43
+ .replace(/[\u0300-\u036f]/g, "")
44
+ .toLowerCase()
45
+ .replace(/[^a-z0-9]+/g, "-")
46
+ .replace(/^-+|-+$/g, "")
47
+ .slice(0, 50);
48
+ }
49
+
50
+ function chapterFolderName(chapter, chapterTitle) {
51
+ if (chapter === null || chapter === undefined) return null;
52
+ const suffix = slugifyPathSegment(chapterTitle);
53
+ return suffix ? `chapter-${chapter}-${suffix}` : `chapter-${chapter}`;
54
+ }
55
+
56
+ function sceneContainerDir(scenesDir, part, chapter, chapterTitle, organizeByChapters = true) {
57
+ const segments = [scenesDir];
58
+ if (!organizeByChapters) {
59
+ return path.join(...segments);
60
+ }
61
+ if (part !== null && part !== undefined) segments.push(`part-${part}`);
62
+ const chapterDir = chapterFolderName(chapter, chapterTitle);
63
+ if (chapterDir) segments.push(chapterDir);
64
+ return path.join(...segments);
65
+ }
66
+
67
+ function findProsePathForSidecar(sidecarPath) {
68
+ const proseCandidates = [
69
+ sidecarPath.replace(/\.meta\.yaml$/, ".md"),
70
+ sidecarPath.replace(/\.meta\.yaml$/, ".txt"),
71
+ ];
72
+ return proseCandidates.find(candidate => fs.existsSync(candidate)) ?? null;
73
+ }
74
+
75
+ function moveFileIfNeeded(fromPath, toPath) {
76
+ if (!fromPath || fromPath === toPath) return;
77
+ fs.mkdirSync(path.dirname(toPath), { recursive: true });
78
+ if (fs.existsSync(toPath)) {
79
+ return {
80
+ moved: false,
81
+ warning: {
82
+ code: "relocate_destination_exists",
83
+ message: "Skipped moving prose file because destination already exists.",
84
+ from_path: fromPath,
85
+ to_path: toPath,
86
+ },
87
+ };
88
+ }
89
+
90
+ try {
91
+ fs.renameSync(fromPath, toPath);
92
+ } catch (error) {
93
+ if (!error || typeof error !== "object" || error.code !== "EXDEV") {
94
+ throw error;
95
+ }
96
+ fs.copyFileSync(fromPath, toPath);
97
+ fs.unlinkSync(fromPath);
98
+ }
99
+
100
+ return { moved: true };
101
+ }
102
+
103
+ const KNOWN_CUSTOM_FIELD_IDS = new Set([
104
+ "savethecat!",
105
+ "causality",
106
+ "stakes",
107
+ "change",
108
+ "f:character",
109
+ "f:mood",
110
+ "f:theme",
111
+ ]);
112
+
113
+ const MAX_RETURNED_WARNINGS = 25;
114
+
115
+ function recordWarning(summary, warning) {
116
+ if (!summary[warning.code]) {
117
+ summary[warning.code] = { count: 0, examples: [] };
118
+ }
119
+
120
+ const entry = summary[warning.code];
121
+ entry.count++;
122
+
123
+ if (entry.examples.length < 5) {
124
+ const example = { message: warning.message };
125
+ for (const key of ["file", "sync_number", "field_id", "value", "uuid", "from_path", "to_path", "moved_to"]) {
126
+ if (warning[key] !== undefined && warning[key] !== null) {
127
+ example[key] = warning[key];
128
+ }
129
+ }
130
+ entry.examples.push(example);
131
+ }
132
+ }
133
+
134
+ function pushWarning(warnings, warningSummary, warning) {
135
+ recordWarning(warningSummary, warning);
136
+
137
+ if (warnings.length < MAX_RETURNED_WARNINGS) {
138
+ warnings.push(warning);
139
+ return false;
140
+ }
141
+
142
+ return true;
143
+ }
144
+
37
145
  function buildMergeDataFromProject(projectData, uuid) {
38
- const { metaByUUID, partByUUID, chapterByUUID } = projectData;
39
- const { customFields, characters, versions, synopsis } = metaByUUID[uuid] ?? {};
146
+ const { metaByUUID, partByUUID, chapterByUUID, chapterTitleByUUID } = projectData;
147
+ const { customFields, tags, synopsis } = metaByUUID[uuid] ?? {};
40
148
  const part = partByUUID[uuid] ?? null;
41
149
  const chapter = chapterByUUID[uuid] ?? null;
150
+ const chapterTitle = chapterTitleByUUID[uuid] ?? null;
151
+ const warnings = [];
42
152
 
43
- if (!customFields && !characters && !versions && !synopsis && part === null && chapter === null) return null;
153
+ if (!customFields && !tags && !synopsis && part === null && chapter === null && !chapterTitle) {
154
+ return { mergeData: null, warnings };
155
+ }
44
156
 
45
157
  const out = {};
46
158
 
47
159
  if (part !== null) out.part = part;
48
160
  if (chapter !== null) out.chapter = chapter;
161
+ if (chapterTitle) out.chapter_title = chapterTitle;
49
162
  if (synopsis) out.synopsis = synopsis;
50
- if (characters?.length) out.characters = characters;
51
- if (versions?.length) out.versions = versions;
163
+ if (tags?.length) out.tags = tags;
164
+
165
+ for (const [fieldId, value] of Object.entries(customFields ?? {})) {
166
+ if (!KNOWN_CUSTOM_FIELD_IDS.has(fieldId) && String(value ?? "").trim()) {
167
+ warnings.push({
168
+ code: "ignored_custom_field",
169
+ message: `Ignored unsupported Scrivener custom field '${fieldId}'.`,
170
+ field_id: fieldId,
171
+ value: String(value),
172
+ uuid,
173
+ });
174
+ }
175
+ }
52
176
 
53
177
  const stcBeat = customFields?.["savethecat!"];
54
178
  if (stcBeat && typeof stcBeat === "string" && stcBeat.trim()) {
55
179
  out.save_the_cat_beat = stcBeat.trim();
56
180
  }
57
181
 
58
- const causality = Number(customFields?.["causality"] ?? 0);
59
- const stakes = Number(customFields?.["stakes"] ?? 0);
182
+ const causalityRaw = customFields?.["causality"];
183
+ const stakesRaw = customFields?.["stakes"];
184
+ const causality = Number(causalityRaw ?? 0);
185
+ const stakes = Number(stakesRaw ?? 0);
186
+ if (causalityRaw !== undefined && String(causalityRaw).trim() && Number.isNaN(causality)) {
187
+ warnings.push({
188
+ code: "invalid_custom_field_value",
189
+ message: "Ignored non-numeric Scrivener custom field value for 'causality'.",
190
+ field_id: "causality",
191
+ value: String(causalityRaw),
192
+ uuid,
193
+ });
194
+ }
195
+ if (stakesRaw !== undefined && String(stakesRaw).trim() && Number.isNaN(stakes)) {
196
+ warnings.push({
197
+ code: "invalid_custom_field_value",
198
+ message: "Ignored non-numeric Scrivener custom field value for 'stakes'.",
199
+ field_id: "stakes",
200
+ value: String(stakesRaw),
201
+ uuid,
202
+ });
203
+ }
60
204
  if (causality) out.causality = causality;
61
205
  if (stakes) out.stakes = stakes;
62
206
 
@@ -69,7 +213,10 @@ function buildMergeDataFromProject(projectData, uuid) {
69
213
  if (customFields?.["f:theme"] === "Yes" || customFields?.["f:theme"] === true) fnFlags.push("theme");
70
214
  if (fnFlags.length) out.scene_functions = fnFlags;
71
215
 
72
- return Object.keys(out).length ? out : null;
216
+ return {
217
+ mergeData: Object.keys(out).length ? out : null,
218
+ warnings,
219
+ };
73
220
  }
74
221
 
75
222
  export function mergeSidecarData(existing, mergeData) {
@@ -144,15 +291,13 @@ export function loadScrivenerProjectData(scrivPath) {
144
291
  }
145
292
  }
146
293
 
147
- const characters = [];
148
- const versions = [];
294
+ const tags = [];
149
295
  const kwEl = children(item, "Keywords")[0];
150
296
  if (kwEl) {
151
297
  for (const kwId of children(kwEl, "KeywordID")) {
152
298
  const name = keywordMap[text(kwId)];
153
299
  if (!name) continue;
154
- if (/^v\d[\d.a-z]*$/i.test(name)) versions.push(name);
155
- else characters.push(name);
300
+ tags.push(name);
156
301
  }
157
302
  }
158
303
 
@@ -163,11 +308,16 @@ export function loadScrivenerProjectData(scrivPath) {
163
308
  if (candidate) synopsis = candidate;
164
309
  }
165
310
 
166
- metaByUUID[uuid] = { customFields, characters, versions, synopsis };
311
+ metaByUUID[uuid] = {
312
+ customFields,
313
+ tags: [...new Set(tags)],
314
+ synopsis,
315
+ };
167
316
  }
168
317
 
169
318
  const partByUUID = {};
170
319
  const chapterByUUID = {};
320
+ const chapterTitleByUUID = {};
171
321
  let partNum = 0;
172
322
  let chapterNum = 0;
173
323
 
@@ -176,21 +326,29 @@ export function loadScrivenerProjectData(scrivPath) {
176
326
  const uuid = attr(child, "UUID");
177
327
  const type = attr(child, "Type");
178
328
  const childrenEl = children(child, "Children")[0];
329
+ const title = text(children(child, "Title")[0]);
179
330
 
180
331
  if (type === "Folder" && currentPart === null) {
181
332
  partNum++;
182
- if (childrenEl) walkHierarchy(childrenEl, partNum, null);
333
+ if (childrenEl) walkHierarchy(childrenEl, { number: partNum, title }, null);
183
334
  } else if (type === "Folder") {
184
335
  chapterNum++;
336
+ const nextChapter = { number: chapterNum, title };
185
337
  if (uuid) {
186
- partByUUID[uuid] = currentPart;
338
+ if (currentPart?.number !== null && currentPart?.number !== undefined) {
339
+ partByUUID[uuid] = currentPart.number;
340
+ }
187
341
  chapterByUUID[uuid] = chapterNum;
342
+ if (title) chapterTitleByUUID[uuid] = title;
188
343
  }
189
- if (childrenEl) walkHierarchy(childrenEl, currentPart, chapterNum);
344
+ if (childrenEl) walkHierarchy(childrenEl, currentPart, nextChapter);
190
345
  } else if (type === "Text") {
191
- if (uuid && currentChapter !== null) {
192
- partByUUID[uuid] = currentPart;
193
- chapterByUUID[uuid] = currentChapter;
346
+ if (uuid && currentChapter?.number !== null && currentChapter?.number !== undefined) {
347
+ if (currentPart?.number !== null && currentPart?.number !== undefined) {
348
+ partByUUID[uuid] = currentPart.number;
349
+ }
350
+ chapterByUUID[uuid] = currentChapter.number;
351
+ if (currentChapter.title) chapterTitleByUUID[uuid] = currentChapter.title;
194
352
  }
195
353
  }
196
354
  }
@@ -214,6 +372,7 @@ export function loadScrivenerProjectData(scrivPath) {
214
372
  metaByUUID,
215
373
  partByUUID,
216
374
  chapterByUUID,
375
+ chapterTitleByUUID,
217
376
  };
218
377
  }
219
378
 
@@ -223,6 +382,7 @@ export function mergeScrivenerProjectMetadata({
223
382
  projectId,
224
383
  scenesDir: scenesDirOverride,
225
384
  dryRun = false,
385
+ organizeByChapters = false,
226
386
  logger = () => {},
227
387
  }) {
228
388
  const mcpSyncDirAbs = path.resolve(mcpSyncDir);
@@ -268,15 +428,25 @@ export function mergeScrivenerProjectMetadata({
268
428
  let unchanged = 0;
269
429
  let noData = 0;
270
430
  let skippedNoBracketId = 0;
431
+ let relocated = 0;
271
432
  const fieldAddCounts = {};
272
433
  const previewChanges = [];
434
+ const warnings = [];
435
+ const warningSummary = {};
436
+ let warningsTruncated = false;
273
437
 
274
438
  for (const sidecarPath of sidecarFiles) {
275
439
  const filename = path.basename(sidecarPath);
440
+ const prosePath = findProsePathForSidecar(sidecarPath);
276
441
  const match = filename.match(/\[(\d+)\]\.meta\.yaml$/);
277
442
  if (!match) {
278
443
  logger(` SKIP (no bracket ID) ${filename}`);
279
444
  skippedNoBracketId++;
445
+ warningsTruncated = pushWarning(warnings, warningSummary, {
446
+ code: "missing_bracket_id",
447
+ message: "Skipped sidecar because filename does not include a Scrivener sync number in brackets.",
448
+ file: filename,
449
+ }) || warningsTruncated;
280
450
  continue;
281
451
  }
282
452
 
@@ -285,10 +455,20 @@ export function mergeScrivenerProjectMetadata({
285
455
  if (!uuid) {
286
456
  logger(` SKIP (no UUID for [${syncNum}]) ${filename}`);
287
457
  noData++;
458
+ warningsTruncated = pushWarning(warnings, warningSummary, {
459
+ code: "missing_uuid_mapping",
460
+ message: `Skipped sidecar because Scrivener sync number [${syncNum}] has no UUID mapping in the project.`,
461
+ file: filename,
462
+ sync_number: syncNum,
463
+ }) || warningsTruncated;
288
464
  continue;
289
465
  }
290
466
 
291
- const mergeData = buildMergeDataFromProject(projectData, uuid);
467
+ const { mergeData, warnings: mergeWarnings } = buildMergeDataFromProject(projectData, uuid);
468
+ for (const warning of mergeWarnings) {
469
+ warningsTruncated = pushWarning(warnings, warningSummary, { ...warning, file: filename }) || warningsTruncated;
470
+ }
471
+
292
472
  if (!mergeData) {
293
473
  unchanged++;
294
474
  continue;
@@ -300,8 +480,22 @@ export function mergeScrivenerProjectMetadata({
300
480
  }
301
481
  const existing = existingRaw ?? {};
302
482
  const { merged, changed, newKeys } = mergeSidecarData(existing, mergeData);
303
-
304
- if (!changed) {
483
+ const effective = changed ? merged : existing;
484
+ const targetDir = sceneContainerDir(
485
+ scenesDir,
486
+ effective.part ?? null,
487
+ effective.chapter ?? null,
488
+ effective.chapter_title ?? null,
489
+ organizeByChapters,
490
+ );
491
+ const targetSidecarPath = organizeByChapters ? path.join(targetDir, filename) : sidecarPath;
492
+ const targetProsePath = prosePath
493
+ ? (organizeByChapters ? path.join(targetDir, path.basename(prosePath)) : prosePath)
494
+ : null;
495
+ const needsMove = path.resolve(sidecarPath) !== path.resolve(targetSidecarPath)
496
+ || (prosePath && targetProsePath && path.resolve(prosePath) !== path.resolve(targetProsePath));
497
+
498
+ if (!changed && !needsMove) {
305
499
  unchanged++;
306
500
  continue;
307
501
  }
@@ -311,23 +505,92 @@ export function mergeScrivenerProjectMetadata({
311
505
  }
312
506
 
313
507
  if (previewChanges.length < 25) {
314
- previewChanges.push({ file: filename, added_keys: [...newKeys] });
508
+ previewChanges.push({
509
+ file: filename,
510
+ added_keys: [...newKeys],
511
+ ...(needsMove ? { moved_to: path.relative(scenesDir, targetSidecarPath) || filename } : {}),
512
+ });
315
513
  }
316
514
 
515
+ let didRelocate = false;
516
+
317
517
  if (dryRun) {
318
518
  logger(` DRY ${filename}`);
319
519
  for (const key of newKeys) {
320
520
  logger(` + ${key}: ${JSON.stringify(mergeData[key]).slice(0, 80)}`);
321
521
  }
522
+ if (needsMove) {
523
+ logger(` -> ${path.relative(scenesDir, targetSidecarPath) || filename}`);
524
+ }
525
+ didRelocate = needsMove;
322
526
  } else {
323
- fs.writeFileSync(sidecarPath, yaml.dump(merged, { lineWidth: 120 }), "utf8");
324
- logger(` OK ${filename} [+${newKeys.join(", ")}]`);
527
+ let proseMoveWarning = null;
528
+ let shouldRelocateSidecar = organizeByChapters;
529
+
530
+ if (
531
+ shouldRelocateSidecar
532
+ && path.resolve(sidecarPath) !== path.resolve(targetSidecarPath)
533
+ && fs.existsSync(targetSidecarPath)
534
+ ) {
535
+ shouldRelocateSidecar = false;
536
+ warningsTruncated = pushWarning(
537
+ warnings,
538
+ warningSummary,
539
+ {
540
+ code: "relocate_sidecar_destination_exists",
541
+ message: "Skipped relocating sidecar because destination already exists.",
542
+ from_path: sidecarPath,
543
+ to_path: targetSidecarPath,
544
+ file: filename,
545
+ }
546
+ ) || warningsTruncated;
547
+ }
548
+
549
+ if (shouldRelocateSidecar && prosePath && targetProsePath) {
550
+ const moveResult = moveFileIfNeeded(prosePath, targetProsePath);
551
+ if (moveResult?.warning) {
552
+ proseMoveWarning = moveResult.warning;
553
+ shouldRelocateSidecar = false;
554
+ warningsTruncated = pushWarning(
555
+ warnings,
556
+ warningSummary,
557
+ {
558
+ ...moveResult.warning,
559
+ file: filename,
560
+ }
561
+ ) || warningsTruncated;
562
+ }
563
+ }
564
+
565
+ const finalSidecarPath = shouldRelocateSidecar ? targetSidecarPath : sidecarPath;
566
+ fs.mkdirSync(path.dirname(finalSidecarPath), { recursive: true });
567
+ fs.writeFileSync(finalSidecarPath, yaml.dump(effective, { lineWidth: 120 }), "utf8");
568
+ if (
569
+ shouldRelocateSidecar
570
+ && path.resolve(sidecarPath) !== path.resolve(targetSidecarPath)
571
+ && fs.existsSync(sidecarPath)
572
+ ) {
573
+ fs.unlinkSync(sidecarPath);
574
+ }
575
+
576
+ const changes = [];
577
+ if (newKeys.length) changes.push(`+${newKeys.join(", ")}`);
578
+ if (needsMove && shouldRelocateSidecar) {
579
+ changes.push(`moved to ${path.relative(scenesDir, targetSidecarPath) || filename}`);
580
+ }
581
+ if (proseMoveWarning) {
582
+ changes.push("sidecar kept in place (prose move skipped)");
583
+ }
584
+ logger(` OK ${filename}${changes.length ? ` [${changes.join("; ")}]` : ""}`);
585
+ didRelocate = needsMove && shouldRelocateSidecar;
325
586
  }
587
+ if (didRelocate) relocated++;
326
588
  updated++;
327
589
  }
328
590
 
329
591
  logger(`\n${"─".repeat(50)}`);
330
592
  logger(`Updated: ${updated} sidecars${dryRun ? " (dry run)" : ""}`);
593
+ if (relocated) logger(`Relocated: ${relocated} scene file pair(s)`);
331
594
  logger(`Unchanged: ${unchanged} (already complete or no new data)`);
332
595
  if (skippedNoBracketId) logger(`Skipped: ${skippedNoBracketId} (no bracket ID in filename)`);
333
596
  if (noData) logger(`No data: ${noData} (no matching binder entry)`);
@@ -340,11 +603,15 @@ export function mergeScrivenerProjectMetadata({
340
603
  dryRun: Boolean(dryRun),
341
604
  sidecarFiles: sidecarFiles.length,
342
605
  updated,
606
+ relocated,
343
607
  unchanged,
344
608
  skippedNoBracketId,
345
609
  noData,
346
610
  fieldAddCounts,
347
611
  previewChanges,
612
+ warnings,
613
+ warningsTruncated,
614
+ warningSummary,
348
615
  stats: {
349
616
  syncMapEntries: Object.keys(projectData.syncNumToUUID).length,
350
617
  keywordMapEntries: Object.keys(projectData.keywordMap).length,
package/sync.js CHANGED
@@ -69,10 +69,10 @@ export function inferScenePositionFromPath(syncDir, filePath) {
69
69
  let chapter = null;
70
70
 
71
71
  for (const segment of parts) {
72
- const partMatch = segment.match(/^part-(\d+)$/i);
72
+ const partMatch = segment.match(/^part-(\d+)(?:-.+)?$/i);
73
73
  if (partMatch) part = parseInt(partMatch[1], 10);
74
74
 
75
- const chapterMatch = segment.match(/^chapter-(\d+)$/i);
75
+ const chapterMatch = segment.match(/^chapter-(\d+)(?:-.+)?$/i);
76
76
  if (chapterMatch) chapter = parseInt(chapterMatch[1], 10);
77
77
  }
78
78
 
@@ -107,8 +107,8 @@ const UNIVERSE_PROJECT_ROOT_CACHE = new Map();
107
107
  function isProjectStructuralDir(name) {
108
108
  const normalized = String(name ?? "").toLowerCase();
109
109
  return PROJECT_STRUCTURAL_DIRS.has(normalized)
110
- || /^part-\d+$/.test(normalized)
111
- || /^chapter-\d+$/.test(normalized);
110
+ || /^part-\d+(?:-.+)?$/.test(normalized)
111
+ || /^chapter-\d+(?:-.+)?$/.test(normalized);
112
112
  }
113
113
 
114
114
  function isBookSlug(name) {
@@ -480,15 +480,16 @@ export function indexSceneFile(db, syncDir, file, meta, prose) {
480
480
 
481
481
  db.prepare(`
482
482
  INSERT INTO scenes (
483
- scene_id, project_id, title, part, chapter, pov, logline, scene_change,
483
+ scene_id, project_id, title, part, chapter, chapter_title, pov, logline, scene_change,
484
484
  causality, stakes, scene_functions,
485
485
  save_the_cat_beat, timeline_position, story_time, word_count,
486
486
  file_path, prose_checksum, metadata_stale, updated_at
487
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
487
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
488
488
  ON CONFLICT (scene_id, project_id) DO UPDATE SET
489
489
  title = excluded.title,
490
490
  part = excluded.part,
491
491
  chapter = excluded.chapter,
492
+ chapter_title = excluded.chapter_title,
492
493
  pov = excluded.pov,
493
494
  logline = excluded.logline,
494
495
  scene_change = excluded.scene_change,
@@ -505,7 +506,7 @@ export function indexSceneFile(db, syncDir, file, meta, prose) {
505
506
  updated_at = excluded.updated_at
506
507
  `).run(
507
508
  meta.scene_id, project_id,
508
- meta.title ?? null, meta.part ?? null, meta.chapter ?? null,
509
+ meta.title ?? null, meta.part ?? null, meta.chapter ?? null, meta.chapter_title ?? null,
509
510
  meta.pov ?? null, meta.logline ?? meta.synopsis ?? null,
510
511
  meta.scene_change ?? meta.change ?? null,
511
512
  meta.causality ?? null, meta.stakes ?? null,