@hanna84/mcp-writing 1.11.8 → 1.13.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.13.0](https://github.com/hannasdev/mcp-writing.git
8
+ /compare/v1.12.0...v1.13.0)
9
+
10
+ - feat(scrivener-direct): add ambiguity warning taxonomy for beta merge [`#69`](https://github.com/hannasdev/mcp-writing.git
11
+ /pull/69)
12
+
13
+ #### [v1.12.0](https://github.com/hannasdev/mcp-writing.git
14
+ /compare/v1.11.8...v1.12.0)
15
+
16
+ > 24 April 2026
17
+
18
+ - feat(scrivener-direct): ownership enforcement (PR-3a) [`#68`](https://github.com/hannasdev/mcp-writing.git
19
+ /pull/68)
20
+ - Release 1.12.0 [`e249edd`](https://github.com/hannasdev/mcp-writing.git
21
+ /commit/e249edd1df6b1f77253200296057fe1cb468d2d0)
22
+
7
23
  #### [v1.11.8](https://github.com/hannasdev/mcp-writing.git
8
24
  /compare/v1.11.7...v1.11.8)
9
25
 
26
+ > 24 April 2026
27
+
10
28
  - chore(deps): remove unused fast-xml-parser and bump @xmldom/xmldom [`#67`](https://github.com/hannasdev/mcp-writing.git
11
29
  /pull/67)
30
+ - Release 1.11.8 [`7329eb4`](https://github.com/hannasdev/mcp-writing.git
31
+ /commit/7329eb4107bb0ac1889d58446604381a47370133)
12
32
 
13
33
  #### [v1.11.7](https://github.com/hannasdev/mcp-writing.git
14
34
  /compare/v1.11.6...v1.11.7)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanna84/mcp-writing",
3
- "version": "1.11.8",
3
+ "version": "1.13.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",
@@ -100,6 +100,21 @@ function moveFileIfNeeded(fromPath, toPath) {
100
100
  return { moved: true };
101
101
  }
102
102
 
103
+ // Fields that are exclusively owned by the stable Scrivener sync-folder importer.
104
+ // The beta merge must never write these fields, even additively, because the
105
+ // importer derives them from the source filename and project structure in ways
106
+ // the beta merge path cannot reliably replicate (e.g. scene_id slug, timeline
107
+ // position from file sequence, external identity).
108
+ export const IMPORTER_AUTHORITATIVE_FIELDS = Object.freeze([
109
+ "scene_id",
110
+ "external_source",
111
+ "external_id",
112
+ "title",
113
+ "timeline_position",
114
+ ]);
115
+
116
+ const IMPORTER_AUTHORITATIVE_FIELD_SET = new Set(IMPORTER_AUTHORITATIVE_FIELDS);
117
+
103
118
  const KNOWN_CUSTOM_FIELD_IDS = new Set([
104
119
  "savethecat!",
105
120
  "causality",
@@ -111,6 +126,73 @@ const KNOWN_CUSTOM_FIELD_IDS = new Set([
111
126
  ]);
112
127
 
113
128
  const MAX_RETURNED_WARNINGS = 25;
129
+ const STRUCTURE_MAPPING_FIELDS = new Set(["part", "chapter", "chapter_title"]);
130
+
131
+ function normalizeComparisonValue(key, value) {
132
+ if ((key === "tags" || key === "scene_functions") && Array.isArray(value)) {
133
+ return [...new Set(value.map(item => String(item)))].sort();
134
+ }
135
+ return value;
136
+ }
137
+
138
+ function valuesEquivalentForMerge(key, a, b) {
139
+ return JSON.stringify(normalizeComparisonValue(key, a)) === JSON.stringify(normalizeComparisonValue(key, b));
140
+ }
141
+
142
+ function collectAmbiguityWarnings(existing, mergeData, { file, uuid }) {
143
+ const out = [];
144
+
145
+ const externalSource = existing?.external_source;
146
+ if (externalSource !== undefined && externalSource !== null && externalSource !== "scrivener") {
147
+ out.push({
148
+ code: "ambiguous_identity_tie",
149
+ message: "Existing sidecar identity source conflicts with Scrivener mapping; keeping existing sidecar identity.",
150
+ reason: "external_source_conflict",
151
+ external_source: String(externalSource),
152
+ file,
153
+ uuid,
154
+ });
155
+ }
156
+ if (externalSource === "scrivener" && (existing?.external_id === undefined || existing?.external_id === null || String(existing.external_id).trim() === "")) {
157
+ out.push({
158
+ code: "ambiguous_identity_tie",
159
+ message: "Existing sidecar identity is marked as Scrivener but missing external_id; keeping existing sidecar identity.",
160
+ reason: "missing_external_id",
161
+ file,
162
+ uuid,
163
+ });
164
+ }
165
+
166
+ for (const [key, scrivenerValue] of Object.entries(mergeData)) {
167
+ if (!Object.hasOwn(existing, key)) continue;
168
+ if (valuesEquivalentForMerge(key, existing[key], scrivenerValue)) continue;
169
+
170
+ if (STRUCTURE_MAPPING_FIELDS.has(key)) {
171
+ out.push({
172
+ code: "ambiguous_structure_mapping",
173
+ message: `Existing sidecar field '${key}' conflicts with Scrivener-derived structure; keeping existing value.`,
174
+ field: key,
175
+ existing_value: existing[key],
176
+ scrivener_value: scrivenerValue,
177
+ file,
178
+ uuid,
179
+ });
180
+ continue;
181
+ }
182
+
183
+ out.push({
184
+ code: "ambiguous_metadata_mapping",
185
+ message: `Existing sidecar field '${key}' conflicts with Scrivener metadata; keeping existing value.`,
186
+ field: key,
187
+ existing_value: existing[key],
188
+ scrivener_value: scrivenerValue,
189
+ file,
190
+ uuid,
191
+ });
192
+ }
193
+
194
+ return out;
195
+ }
114
196
 
115
197
  function recordWarning(summary, warning) {
116
198
  if (!summary[warning.code]) {
@@ -122,7 +204,21 @@ function recordWarning(summary, warning) {
122
204
 
123
205
  if (entry.examples.length < 5) {
124
206
  const example = { message: warning.message };
125
- for (const key of ["file", "sync_number", "field_id", "value", "uuid", "from_path", "to_path", "moved_to"]) {
207
+ for (const key of [
208
+ "file",
209
+ "sync_number",
210
+ "field",
211
+ "field_id",
212
+ "value",
213
+ "reason",
214
+ "external_source",
215
+ "existing_value",
216
+ "scrivener_value",
217
+ "uuid",
218
+ "from_path",
219
+ "to_path",
220
+ "moved_to",
221
+ ]) {
126
222
  if (warning[key] !== undefined && warning[key] !== null) {
127
223
  example[key] = warning[key];
128
224
  }
@@ -222,8 +318,13 @@ function buildMergeDataFromProject(projectData, uuid) {
222
318
  export function mergeSidecarData(existing, mergeData) {
223
319
  const merged = { ...existing };
224
320
  const newKeys = [];
321
+ const blockedKeys = [];
225
322
 
226
323
  for (const [key, value] of Object.entries(mergeData)) {
324
+ if (IMPORTER_AUTHORITATIVE_FIELD_SET.has(key)) {
325
+ blockedKeys.push(key);
326
+ continue;
327
+ }
227
328
  if (!(key in merged)) {
228
329
  merged[key] = value;
229
330
  newKeys.push(key);
@@ -234,6 +335,7 @@ export function mergeSidecarData(existing, mergeData) {
234
335
  merged,
235
336
  changed: newKeys.length > 0,
236
337
  newKeys,
338
+ blockedKeys,
237
339
  };
238
340
  }
239
341
 
@@ -479,6 +581,11 @@ export function mergeScrivenerProjectMetadata({
479
581
  throw new Error(`Invalid sidecar YAML mapping at ${sidecarPath}`);
480
582
  }
481
583
  const existing = existingRaw ?? {};
584
+ const ambiguityWarnings = collectAmbiguityWarnings(existing, mergeData, { file: filename, uuid });
585
+ for (const warning of ambiguityWarnings) {
586
+ warningsTruncated = pushWarning(warnings, warningSummary, warning) || warningsTruncated;
587
+ }
588
+
482
589
  const { merged, changed, newKeys } = mergeSidecarData(existing, mergeData);
483
590
  const effective = changed ? merged : existing;
484
591
  const targetDir = sceneContainerDir(