@hanna84/mcp-writing 1.13.1 → 1.14.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,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.14.0](https://github.com/hannasdev/mcp-writing.git
8
+ /compare/v1.13.2...v1.14.0)
9
+
10
+ - feat(scrivener-direct): graduate from beta to stable [`#72`](https://github.com/hannasdev/mcp-writing.git
11
+ /pull/72)
12
+
13
+ #### [v1.13.2](https://github.com/hannasdev/mcp-writing.git
14
+ /compare/v1.13.1...v1.13.2)
15
+
16
+ > 24 April 2026
17
+
18
+ - fix(scrivener-direct): add Phase E data safety hardening [`#71`](https://github.com/hannasdev/mcp-writing.git
19
+ /pull/71)
20
+ - Release 1.13.2 [`8b98c80`](https://github.com/hannasdev/mcp-writing.git
21
+ /commit/8b98c80e27446d41f42c88217253ab5d30b065f2)
22
+
7
23
  #### [v1.13.1](https://github.com/hannasdev/mcp-writing.git
8
24
  /compare/v1.13.0...v1.13.1)
9
25
 
26
+ > 24 April 2026
27
+
10
28
  - fix(mcp): report server version from package metadata [`#70`](https://github.com/hannasdev/mcp-writing.git
11
29
  /pull/70)
30
+ - Release 1.13.1 [`c9a5ff5`](https://github.com/hannasdev/mcp-writing.git
31
+ /commit/c9a5ff5e847767cad2d73c170af7b28aede1ec59)
12
32
 
13
33
  #### [v1.13.0](https://github.com/hannasdev/mcp-writing.git
14
34
  /compare/v1.12.0...v1.13.0)
package/git.js CHANGED
@@ -1,4 +1,4 @@
1
- import { execSync } from "node:child_process";
1
+ import { execFileSync, execSync } from "node:child_process";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
 
@@ -14,7 +14,7 @@ export function isGitRepository(dirPath) {
14
14
  stdio: "pipe",
15
15
  encoding: "utf8",
16
16
  }).trim();
17
- return path.resolve(gitRoot) === path.resolve(dirPath);
17
+ return fs.realpathSync(gitRoot) === fs.realpathSync(dirPath);
18
18
  } catch {
19
19
  return false;
20
20
  }
@@ -52,26 +52,35 @@ export function isGitAvailable() {
52
52
  }
53
53
 
54
54
  /**
55
- * Create a git commit for a scene file (pre-edit snapshot)
55
+ * Create a git commit for one or more scene-related files
56
56
  * Returns { commit_hash: string, commit_message: string }
57
57
  */
58
- export function createSnapshot(dirPath, filePath, sceneId, instruction) {
58
+ export function createSnapshot(dirPath, filePath, sceneId, instruction, options = {}) {
59
59
  try {
60
- const relPath = path.relative(dirPath, filePath);
61
- execSync(`git add "${relPath}"`, { cwd: dirPath, stdio: "pipe" });
60
+ const { messagePrefix = "pre-edit snapshot" } = options;
61
+ const inputPaths = Array.isArray(filePath) ? filePath : [filePath];
62
+ const relPaths = [...new Set(inputPaths
63
+ .filter(Boolean)
64
+ .map(p => path.relative(dirPath, p)))];
65
+ if (!relPaths.length) {
66
+ throw new Error("No file paths provided for snapshot");
67
+ }
68
+ // Use -A so removed/renamed paths are staged as part of relocation snapshots.
69
+ execFileSync("git", ["add", "-A", "--", ...relPaths], { cwd: dirPath, stdio: "pipe" });
62
70
 
63
- const commitMessage = `pre-edit snapshot: ${sceneId} — ${instruction}`;
64
- // Use 2>&1 so git's stderr (where it prints "[branch hash] msg") is captured in stdout
65
- const output = execSync(`git commit -m "${commitMessage.replace(/"/g, '\\"')}" 2>&1`, {
71
+ const commitMessage = messagePrefix
72
+ ? `${messagePrefix}: ${sceneId} ${instruction}`
73
+ : String(instruction);
74
+
75
+ execFileSync("git", ["commit", "-m", commitMessage], {
66
76
  cwd: dirPath,
67
- encoding: "utf8",
68
77
  stdio: "pipe",
69
78
  });
70
-
71
- // git outputs "[branch hash] message" to stderr; redirect 2>&1 captures it in stdout
72
- // Regex handles any branch name, with or without (root-commit)
73
- const match = output.match(/\[\S+(?:\s+\(root-commit\))?\s+([a-f0-9]+)\]/);
74
- const commitHash = match ? match[1] : null;
79
+ const commitHash = execFileSync("git", ["rev-parse", "HEAD"], {
80
+ cwd: dirPath,
81
+ stdio: "pipe",
82
+ encoding: "utf8",
83
+ }).trim();
75
84
 
76
85
  return {
77
86
  commit_hash: commitHash,
@@ -79,7 +88,10 @@ export function createSnapshot(dirPath, filePath, sceneId, instruction) {
79
88
  };
80
89
  } catch (err) {
81
90
  // Check if nothing changed (no error, just no commit)
82
- if (err.message.includes("nothing to commit") || err.status === 1) {
91
+ const stderr = err?.stderr ? String(err.stderr) : "";
92
+ const stdout = err?.stdout ? String(err.stdout) : "";
93
+ const text = `${stderr}\n${stdout}\n${err?.message ?? ""}`;
94
+ if (text.includes("nothing to commit") || err.status === 1) {
83
95
  return {
84
96
  commit_hash: null,
85
97
  commit_message: null,
package/index.js CHANGED
@@ -980,7 +980,7 @@ function createMcpServer() {
980
980
 
981
981
  s.tool(
982
982
  "merge_scrivener_project_beta",
983
- "[BETA] Merge metadata directly from a Scrivener .scriv project into existing scene sidecars by starting a background job. 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. Returns immediately with a job_id to poll via get_async_job_status.",
983
+ "Merge metadata directly from a Scrivener .scriv project into existing scene sidecars by starting a background job. This path is opt-in and requires sidecars to already exist (for example, from import_scrivener_sync). Returns immediately with a job_id to poll via get_async_job_status.",
984
984
  {
985
985
  source_project_dir: z.string().describe("Path to a Scrivener .scriv bundle directory."),
986
986
  project_id: z.string().optional().describe("Project ID containing existing sidecars (e.g. 'the-lamb' or 'universe-1/book-1-the-lamb')."),
@@ -1052,7 +1052,6 @@ function createMcpServer() {
1052
1052
  return jsonResponse({
1053
1053
  ok: true,
1054
1054
  async: true,
1055
- beta: true,
1056
1055
  job: toPublicJob(job, false),
1057
1056
  next_step: "Call get_async_job_status with job_id until status is 'completed' or 'failed'.",
1058
1057
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanna84/mcp-writing",
3
- "version": "1.13.1",
3
+ "version": "1.14.0",
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",
@@ -52,7 +52,6 @@ function normalizeImportResult(importResult) {
52
52
  function normalizeMergeResult(mergeResult) {
53
53
  return {
54
54
  ok: true,
55
- beta: true,
56
55
  merge: {
57
56
  source_project_dir: mergeResult.scrivPath,
58
57
  sync_dir: mergeResult.mcpSyncDir,
@@ -77,10 +76,7 @@ function normalizeMergeResult(mergeResult) {
77
76
  },
78
77
  },
79
78
  sync: null,
80
- warnings: [
81
- "BETA_FEATURE: Direct Scrivener project parsing may be sensitive to Scrivener internal format changes.",
82
- "If this fails, use import_scrivener_sync with an External Folder Sync export as the stable fallback.",
83
- ],
79
+ warnings: [],
84
80
  };
85
81
  }
86
82
 
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { DOMParser } from "@xmldom/xmldom";
4
4
  import yaml from "js-yaml";
5
5
  import { validateProjectId } from "./importer.js";
6
+ import { createSnapshot, isGitRepository, isGitAvailable } from "./git.js";
6
7
 
7
8
  function attr(el, name) {
8
9
  return el?.getAttribute?.(name) ?? null;
@@ -93,8 +94,60 @@ function moveFileIfNeeded(fromPath, toPath) {
93
94
  if (!error || typeof error !== "object" || error.code !== "EXDEV") {
94
95
  throw error;
95
96
  }
96
- fs.copyFileSync(fromPath, toPath);
97
- fs.unlinkSync(fromPath);
97
+ // Cross-filesystem: copy then verify before unlink
98
+ try {
99
+ fs.copyFileSync(fromPath, toPath);
100
+ // Verify copy succeeded before deleting source
101
+ if (!fs.existsSync(toPath)) {
102
+ return {
103
+ moved: false,
104
+ warning: {
105
+ code: "relocate_copy_verification_failed",
106
+ message: "Failed to verify file copy to destination; source file preserved.",
107
+ from_path: fromPath,
108
+ to_path: toPath,
109
+ },
110
+ };
111
+ }
112
+ try {
113
+ fs.unlinkSync(fromPath);
114
+ } catch (unlinkErr) {
115
+ if (fs.existsSync(toPath)) {
116
+ try {
117
+ fs.unlinkSync(toPath);
118
+ } catch {
119
+ // Best effort; already in error state
120
+ }
121
+ }
122
+ return {
123
+ moved: false,
124
+ warning: {
125
+ code: "relocate_cross_filesystem_unlink_failed",
126
+ message: `Copied prose file but failed to remove source file: ${unlinkErr.message}. Source file preserved.`,
127
+ from_path: fromPath,
128
+ to_path: toPath,
129
+ },
130
+ };
131
+ }
132
+ } catch (copyErr) {
133
+ // Copy failed; ensure we don't leave partial destination files
134
+ if (fs.existsSync(toPath)) {
135
+ try {
136
+ fs.unlinkSync(toPath);
137
+ } catch {
138
+ // Best effort; already in error state
139
+ }
140
+ }
141
+ return {
142
+ moved: false,
143
+ warning: {
144
+ code: "relocate_cross_filesystem_copy_failed",
145
+ message: `Failed to copy prose file across filesystem: ${copyErr.message}. Source file preserved.`,
146
+ from_path: fromPath,
147
+ to_path: toPath,
148
+ },
149
+ };
150
+ }
98
151
  }
99
152
 
100
153
  return { moved: true };
@@ -339,7 +392,8 @@ export function mergeSidecarData(existing, mergeData) {
339
392
  };
340
393
  }
341
394
 
342
- export function loadScrivenerProjectData(scrivPath) {
395
+ export function loadScrivenerProjectData(scrivPath, options = {}) {
396
+ const { onWarning } = options;
343
397
  const scrivPathAbs = path.resolve(scrivPath);
344
398
  if (!fs.existsSync(scrivPathAbs)) {
345
399
  throw new Error(`Scrivener bundle not found: ${scrivPathAbs}`);
@@ -361,6 +415,19 @@ export function loadScrivenerProjectData(scrivPath) {
361
415
  const scrivxPath = path.join(scrivPathAbs, preferredScrivx);
362
416
  const dataDir = path.join(scrivPathAbs, "Files", "Data");
363
417
 
418
+ // XML size guardrail: surface warning if .scrivx is very large.
419
+ const scrivxStat = fs.statSync(scrivxPath);
420
+ const MAX_SCRIVX_SIZE = 50 * 1024 * 1024; // 50MB
421
+ if (scrivxStat.size > MAX_SCRIVX_SIZE) {
422
+ onWarning?.({
423
+ code: "large_scrivx_file",
424
+ message: `Scrivener project .scrivx file is unusually large (${Math.round(scrivxStat.size / 1024 / 1024)}MB). This is an advisory warning; parsing will continue but may take longer and use significantly more memory because XML is parsed in-memory.`,
425
+ scrivx_path: scrivxPath,
426
+ size_bytes: scrivxStat.size,
427
+ threshold_bytes: MAX_SCRIVX_SIZE,
428
+ });
429
+ }
430
+
364
431
  const xml = fs.readFileSync(scrivxPath, "utf8");
365
432
  const dom = new DOMParser().parseFromString(xml, "text/xml");
366
433
 
@@ -517,7 +584,16 @@ export function mergeScrivenerProjectMetadata({
517
584
  throw new Error(`Scenes directory not found or not a directory: ${scenesDir}`);
518
585
  }
519
586
 
520
- const projectData = loadScrivenerProjectData(scrivPath);
587
+ const warnings = [];
588
+ const warningSummary = {};
589
+ let warningsTruncated = false;
590
+
591
+ const projectData = loadScrivenerProjectData(scrivPath, {
592
+ onWarning: (warning) => {
593
+ warningsTruncated = pushWarning(warnings, warningSummary, warning) || warningsTruncated;
594
+ logger(` WARN ${warning.message}`);
595
+ },
596
+ });
521
597
  logger(`Sync map: ${Object.keys(projectData.syncNumToUUID).length} entries`);
522
598
  logger(`Keyword map: ${Object.keys(projectData.keywordMap).length} entries`);
523
599
  logger(`Binder items collected: ${Object.keys(projectData.metaByUUID).length}`);
@@ -533,10 +609,7 @@ export function mergeScrivenerProjectMetadata({
533
609
  let relocated = 0;
534
610
  const fieldAddCounts = {};
535
611
  const previewChanges = [];
536
- const warnings = [];
537
- const warningSummary = {};
538
- let warningsTruncated = false;
539
-
612
+ const canCreateSnapshots = !dryRun && isGitAvailable() && isGitRepository(mcpSyncDirAbs);
540
613
  for (const sidecarPath of sidecarFiles) {
541
614
  const filename = path.basename(sidecarPath);
542
615
  const prosePath = findProsePathForSidecar(sidecarPath);
@@ -599,7 +672,20 @@ export function mergeScrivenerProjectMetadata({
599
672
  const targetProsePath = prosePath
600
673
  ? (organizeByChapters ? path.join(targetDir, path.basename(prosePath)) : prosePath)
601
674
  : null;
602
- const needsMove = path.resolve(sidecarPath) !== path.resolve(targetSidecarPath)
675
+ const desiredSidecarRelocation = organizeByChapters
676
+ && path.resolve(sidecarPath) !== path.resolve(targetSidecarPath);
677
+
678
+ // Orphan detection: warn once if sidecar has no matching prose and relocation would be attempted.
679
+ if (!prosePath && desiredSidecarRelocation) {
680
+ warningsTruncated = pushWarning(warnings, warningSummary, {
681
+ code: "sidecar_missing_prose",
682
+ message: "Sidecar has no matching prose file (.md or .txt); relocation skipped to preserve consistency.",
683
+ file: filename,
684
+ uuid,
685
+ }) || warningsTruncated;
686
+ }
687
+
688
+ const needsMove = desiredSidecarRelocation
603
689
  || (prosePath && targetProsePath && path.resolve(prosePath) !== path.resolve(targetProsePath));
604
690
 
605
691
  if (!changed && !needsMove) {
@@ -629,10 +715,14 @@ export function mergeScrivenerProjectMetadata({
629
715
  if (needsMove) {
630
716
  logger(` -> ${path.relative(scenesDir, targetSidecarPath) || filename}`);
631
717
  }
632
- didRelocate = needsMove;
718
+ const dryRunRelocateEligible = desiredSidecarRelocation
719
+ && prosePath
720
+ && (!fs.existsSync(targetSidecarPath))
721
+ && (!targetProsePath || path.resolve(prosePath) === path.resolve(targetProsePath) || !fs.existsSync(targetProsePath));
722
+ didRelocate = needsMove && dryRunRelocateEligible;
633
723
  } else {
634
724
  let proseMoveWarning = null;
635
- let shouldRelocateSidecar = organizeByChapters;
725
+ let shouldRelocateSidecar = desiredSidecarRelocation;
636
726
 
637
727
  if (
638
728
  shouldRelocateSidecar
@@ -653,6 +743,11 @@ export function mergeScrivenerProjectMetadata({
653
743
  ) || warningsTruncated;
654
744
  }
655
745
 
746
+ // Prevent relocation if sidecar is orphaned (no matching prose)
747
+ if (shouldRelocateSidecar && !prosePath) {
748
+ shouldRelocateSidecar = false;
749
+ }
750
+
656
751
  if (shouldRelocateSidecar && prosePath && targetProsePath) {
657
752
  const moveResult = moveFileIfNeeded(prosePath, targetProsePath);
658
753
  if (moveResult?.warning) {
@@ -680,6 +775,25 @@ export function mergeScrivenerProjectMetadata({
680
775
  fs.unlinkSync(sidecarPath);
681
776
  }
682
777
 
778
+ // Create git snapshot for audit trail (only on actual writes, not dry-run)
779
+ if (canCreateSnapshots) {
780
+ try {
781
+ const sceneId = existing?.scene_id ?? `[${syncNum}]`;
782
+ const commitMessage = `beta merge Scrivener project metadata [${uuid}]`;
783
+ let snapshotPaths = finalSidecarPath;
784
+ if (shouldRelocateSidecar) {
785
+ snapshotPaths = [finalSidecarPath, sidecarPath];
786
+ if (prosePath && targetProsePath && path.resolve(prosePath) !== path.resolve(targetProsePath)) {
787
+ snapshotPaths.push(prosePath, targetProsePath);
788
+ }
789
+ }
790
+ createSnapshot(mcpSyncDirAbs, snapshotPaths, sceneId, commitMessage, { messagePrefix: null });
791
+ } catch (err) {
792
+ // Snapshot creation errors should not block the merge; log and continue
793
+ logger(` WARN git snapshot creation failed for ${filename}: ${err.message}`);
794
+ }
795
+ }
796
+
683
797
  const changes = [];
684
798
  if (newKeys.length) changes.push(`+${newKeys.join(", ")}`);
685
799
  if (needsMove && shouldRelocateSidecar) {