@hanna84/mcp-writing 3.21.2 → 3.22.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.
@@ -1,4 +1,4 @@
1
- import { readMeta, writeMeta, normalizeReferenceLinkList } from "../sync/sync.js";
1
+ import { readSourceMeta, writeMeta, normalizeReferenceLinkList } from "../sync/sync.js";
2
2
 
3
3
  let savepointCounter = 0;
4
4
 
@@ -13,7 +13,7 @@ export function upsertSerializedReferenceLinks(existing, targetDocId, relation,
13
13
  }
14
14
 
15
15
  export function persistSceneReferenceLink({ scenePath, syncDir, targetDocId, relation }) {
16
- const { meta } = readMeta(scenePath, syncDir, { writable: true });
16
+ const { sourceMeta: meta } = readSourceMeta(scenePath, syncDir, { writable: true });
17
17
  const existingExplicit = [
18
18
  ...(Array.isArray(meta.reference_links) ? meta.reference_links : meta.reference_links ? [meta.reference_links] : []),
19
19
  ...(Array.isArray(meta.explicit_reference_links) ? meta.explicit_reference_links : meta.explicit_reference_links ? [meta.explicit_reference_links] : []),
@@ -59,6 +59,11 @@ export function upsertExplicitReferenceLinkRow(
59
59
  insertStmt.run(sourceKind, sourceProjectId, sourceId, targetDocId, relation);
60
60
  };
61
61
 
62
+ if (db.inTransaction) {
63
+ runUpsertBody();
64
+ return;
65
+ }
66
+
62
67
  if (typeof db.transaction === "function") {
63
68
  db.transaction(runUpsertBody)();
64
69
  return;
@@ -2,7 +2,10 @@ import { z } from "zod";
2
2
  import fs from "node:fs";
3
3
  import matter from "gray-matter";
4
4
  import { readMeta } from "../sync/sync.js";
5
- import { persistSceneReferenceLink, upsertExplicitReferenceLinkRow } from "./reference-link-persistence.js";
5
+ import {
6
+ persistSceneReferenceLink,
7
+ upsertExplicitReferenceLinkRow,
8
+ } from "./reference-link-persistence.js";
6
9
  import { resolveValidatedChapterFilter } from "../core/chapter-resolution.js";
7
10
  import {
8
11
  createToolActor,
@@ -606,7 +609,7 @@ export function registerSearchTools(s, {
606
609
  // ---- get_place_sheet -----------------------------------------------------
607
610
  s.tool(
608
611
  "get_place_sheet",
609
- "Get full place details: associated_characters, tags, the canonical sheet content, and any adjacent support notes when the place uses a folder-based layout. Use this when the current scene or question makes the place itself materially relevant. Response shape note: returns a structured envelope (`results`, `total_count`) with one result row.",
612
+ "Get full place details, including canonical sheet content plus retained sidecar associated_characters and tags as compatibility/review notes. Use connect_character_place_evidence for current scene-backed character/place authority. Response shape note: returns a structured envelope (`results`, `total_count`) with one result row.",
610
613
  {
611
614
  place_id: z.string().describe("The place_id to look up (e.g. 'place-harbor-district'). Use list_places to find valid IDs."),
612
615
  },
@@ -1073,7 +1076,7 @@ export function registerSearchTools(s, {
1073
1076
  // ---- suggest_scene_references --------------------------------------------
1074
1077
  s.tool(
1075
1078
  "suggest_scene_references",
1076
- "Suggest reference documents for a scene by aggregating links from the scene's characters and places. Returns weighted candidates ranked by how many entities in the scene link to each reference. Excludes any explicit scene reference links already present. In apply mode, can persist selected suggestions as explicit scene links in one call.",
1079
+ "Suggest reference documents for a scene by aggregating links from the scene's characters and places. Returns weighted candidates ranked by how many entities in the scene link to each reference. Excludes any explicit scene reference links already present. In apply mode, selected links commit to SQLite first, project backups refresh, and scene sidecar/frontmatter output is refreshed only as generated compatibility.",
1077
1080
  {
1078
1081
  scene_id: z.string().describe("Scene ID (e.g. 'sc-011-sebastian')."),
1079
1082
  project_id: z.string().optional().describe("Optional project scope to disambiguate an ambiguous scene_id across projects."),
@@ -1235,40 +1238,13 @@ export function registerSearchTools(s, {
1235
1238
 
1236
1239
 
1237
1240
  if (mode === "apply") {
1238
- if (!resolvedScene.file_path) {
1239
- return errorResponse("STALE_PATH", `Scene '${scene_id}' has no indexed file path. Run sync() to refresh.`, {
1240
- scene_id,
1241
- project_id: resolvedProjectId,
1242
- });
1243
- }
1244
-
1245
- const toApply = selectApplyCandidates(enriched, selected_doc_ids, max_apply);
1246
-
1241
+ const toApply = selectApplyCandidates(enriched, selected_doc_ids, max_apply);
1247
1242
  const appliedLinks = [];
1248
1243
  const failedLinks = [];
1249
1244
 
1250
- for (const candidate of toApply) {
1251
- try {
1252
- persistSceneReferenceLink({
1253
- scenePath: resolvedScene.file_path,
1254
- syncDir: SYNC_DIR,
1255
- targetDocId: candidate.doc_id,
1256
- relation: candidate.relation,
1257
- });
1258
- } catch (err) {
1259
- failedLinks.push({
1260
- target_doc_id: candidate.doc_id,
1261
- relation: candidate.relation,
1262
- stage: "metadata",
1263
- code: err?.code ?? "IO_ERROR",
1264
- message: err?.code === "ENOENT"
1265
- ? `Scene file for '${scene_id}' not found at indexed path — run sync() to refresh.`
1266
- : `Failed to persist scene reference link metadata: ${err.message}`,
1267
- });
1268
- continue;
1269
- }
1270
-
1271
- try {
1245
+ try {
1246
+ db.exec("BEGIN");
1247
+ for (const candidate of toApply) {
1272
1248
  upsertExplicitReferenceLinkRow(db, {
1273
1249
  sourceKind: "scene",
1274
1250
  sourceProjectId: resolvedProjectId,
@@ -1276,7 +1252,15 @@ export function registerSearchTools(s, {
1276
1252
  targetDocId: candidate.doc_id,
1277
1253
  relation: candidate.relation,
1278
1254
  });
1279
- } catch (err) {
1255
+ }
1256
+ db.exec("COMMIT");
1257
+ } catch (err) {
1258
+ try {
1259
+ db.exec("ROLLBACK");
1260
+ } catch (rollbackErr) {
1261
+ void rollbackErr;
1262
+ }
1263
+ for (const candidate of toApply) {
1280
1264
  failedLinks.push({
1281
1265
  target_doc_id: candidate.doc_id,
1282
1266
  relation: candidate.relation,
@@ -1284,17 +1268,20 @@ export function registerSearchTools(s, {
1284
1268
  code: err?.code ?? "IO_ERROR",
1285
1269
  message: `Failed to persist scene reference link index row: ${err.message}`,
1286
1270
  });
1287
- continue;
1288
1271
  }
1272
+ }
1289
1273
 
1290
- appliedLinks.push({
1291
- source_kind: "scene",
1292
- source_project_id: resolvedProjectId,
1293
- source_id: scene_id,
1294
- target_doc_id: candidate.doc_id,
1295
- relation: candidate.relation,
1296
- origin: "explicit",
1297
- });
1274
+ if (failedLinks.length === 0) {
1275
+ for (const candidate of toApply) {
1276
+ appliedLinks.push({
1277
+ source_kind: "scene",
1278
+ source_project_id: resolvedProjectId,
1279
+ source_id: scene_id,
1280
+ target_doc_id: candidate.doc_id,
1281
+ relation: candidate.relation,
1282
+ origin: "explicit",
1283
+ });
1284
+ }
1298
1285
  }
1299
1286
 
1300
1287
  const backupResult = appliedLinks.length
@@ -1323,6 +1310,47 @@ export function registerSearchTools(s, {
1323
1310
  backup_warnings: [],
1324
1311
  };
1325
1312
 
1313
+ const compatibilityDiagnostics = [];
1314
+ if (appliedLinks.length) {
1315
+ if (!resolvedScene.file_path) {
1316
+ compatibilityDiagnostics.push({
1317
+ code: "STALE_PATH",
1318
+ severity: "warning",
1319
+ message: `Canonical scene reference link was committed, but scene '${scene_id}' has no indexed file path for generated compatibility output.`,
1320
+ next_step: "Treat SQLite and project backup artifacts as current. Run sync, inspect the indexed scene path, then retry suggest_scene_references in apply mode if compatibility output is still needed.",
1321
+ details: {
1322
+ scene_id,
1323
+ project_id: resolvedProjectId,
1324
+ indexed_path: null,
1325
+ },
1326
+ });
1327
+ } else {
1328
+ for (const link of appliedLinks) {
1329
+ try {
1330
+ persistSceneReferenceLink({
1331
+ scenePath: resolvedScene.file_path,
1332
+ syncDir: SYNC_DIR,
1333
+ targetDocId: link.target_doc_id,
1334
+ relation: link.relation,
1335
+ });
1336
+ } catch (err) {
1337
+ compatibilityDiagnostics.push({
1338
+ code: err?.code ?? "COMPATIBILITY_OUTPUT_FAILED",
1339
+ severity: "warning",
1340
+ message: `Canonical scene reference link was committed, but generated compatibility metadata for scene '${scene_id}' could not be refreshed: ${err.message}`,
1341
+ next_step: "Treat SQLite and project backup artifacts as current. Run sync, inspect the indexed scene path, then retry suggest_scene_references in apply mode if compatibility output is still needed.",
1342
+ details: {
1343
+ scene_id,
1344
+ project_id: resolvedProjectId,
1345
+ target_doc_id: link.target_doc_id,
1346
+ indexed_path: resolvedScene.file_path,
1347
+ },
1348
+ });
1349
+ }
1350
+ }
1351
+ }
1352
+ }
1353
+
1326
1354
  return {
1327
1355
  content: [{
1328
1356
  type: "text",
@@ -1336,6 +1364,18 @@ export function registerSearchTools(s, {
1336
1364
  applied_links: appliedLinks,
1337
1365
  failed_count: failedLinks.length,
1338
1366
  failed_links: failedLinks,
1367
+ mutation_order: [
1368
+ "validated_request",
1369
+ "sqlite_commit",
1370
+ "project_backup_refresh",
1371
+ "compatibility_output_refresh",
1372
+ ],
1373
+ compatibility_output: {
1374
+ generated_transparency: true,
1375
+ mutation_surface: false,
1376
+ refreshed: compatibilityDiagnostics.length === 0,
1377
+ },
1378
+ compatibility_diagnostics: compatibilityDiagnostics,
1339
1379
  operation_history: backupResult.operation_history,
1340
1380
  backup_refresh: backupResult.backup_refresh,
1341
1381
  backup_warnings: backupResult.backup_warnings,
package/src/tools/sync.js CHANGED
@@ -2,7 +2,7 @@ import { z } from "zod";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import matter from "gray-matter";
5
- import { syncAll, writeMeta, readMeta, indexSceneFile, normalizeSceneMetaForPath, isManagedStructureProject } from "../sync/sync.js";
5
+ import { syncAll, writeMeta, readSourceMeta, indexSceneFile, normalizeSceneMetaForPath, isManagedStructureProject } from "../sync/sync.js";
6
6
  import { importScrivenerSync, validateProjectId } from "../sync/importer.js";
7
7
  import { runStructureDiagnostics } from "../structure/structure-diagnostics.js";
8
8
  import {
@@ -676,7 +676,7 @@ export function registerSyncTools(s, {
676
676
 
677
677
  s.tool(
678
678
  "enrich_scene_characters_batch",
679
- "Start an asynchronous batch job that infers scene character mentions and updates scene metadata links. Version 1 uses canonical character names only (no aliases). Defaults to dry_run=true.",
679
+ "Start an asynchronous prose-derived relationship repair job that infers scene character mentions and updates scene character links. Defaults to dry_run=true; apply mode retains scene sidecar characters as compatibility output, then syncs SQLite scene_characters and refreshes project backups for changed scenes.",
680
680
  {
681
681
  project_id: z.string().describe("Project ID (e.g. 'the-lamb' or 'universe-1/book-1-the-lamb')."),
682
682
  scene_ids: z.array(z.string()).optional().describe("Optional allowlist of scene IDs to process before other filters are applied."),
@@ -684,7 +684,7 @@ export function registerSyncTools(s, {
684
684
  chapter: z.number().int().optional().describe("Optional read-scope compatibility alias resolved through canonical chapter identity. Not a structural mutation target."),
685
685
  chapter_id: z.string().optional().describe("Optional canonical chapter identifier."),
686
686
  only_stale: z.boolean().optional().describe("If true, only process scenes currently marked metadata_stale."),
687
- dry_run: z.boolean().optional().describe("If true (default), returns preview results without writing sidecars."),
687
+ dry_run: z.boolean().optional().describe("If true (default), returns preview results without writing compatibility metadata."),
688
688
  replace_mode: z.enum(["merge", "replace"]).optional().describe("merge (default): add inferred IDs; replace: overwrite characters with inferred IDs."),
689
689
  max_scenes: z.number().int().positive().optional().describe("Hard guardrail for resolved scene count (default: 200)."),
690
690
  include_match_details: z.boolean().optional().describe("If true, include extra match diagnostics per scene."),
@@ -805,6 +805,18 @@ export function registerSyncTools(s, {
805
805
  });
806
806
  completedJob.result = {
807
807
  ...completedJob.result,
808
+ mutation_order: [
809
+ "prose_inference",
810
+ "compatibility_output_refresh",
811
+ "sqlite_sync_index",
812
+ "project_backup_refresh",
813
+ ],
814
+ compatibility_output: {
815
+ role: "prose_derived_repair_output",
816
+ generated_transparency: true,
817
+ mutation_surface: false,
818
+ refreshed: true,
819
+ },
808
820
  ...backupMutationFields(backupResult),
809
821
  };
810
822
  }
@@ -951,20 +963,21 @@ export function registerSyncTools(s, {
951
963
  try {
952
964
  const raw = fs.readFileSync(scene.file_path, "utf8");
953
965
  const { content: prose } = matter(raw);
954
- const { meta } = readMeta(scene.file_path, SYNC_DIR, { writable: true });
966
+ const { sourceMeta: meta } = readSourceMeta(scene.file_path, SYNC_DIR, { writable: true });
955
967
 
956
968
  const inferredLogline = deriveLoglineFromProse(prose);
957
969
  const inferredCharacters = inferCharacterIdsFromProse(db, prose, scene.project_id);
958
970
 
959
- const updatedMeta = normalizeSceneMetaForPath(SYNC_DIR, scene.file_path, {
971
+ const sourceUpdatedMeta = {
960
972
  ...meta,
961
973
  ...(inferredLogline ? { logline: inferredLogline } : {}),
962
974
  ...((inferredCharacters.length > 0 || (meta.characters?.length ?? 0) > 0)
963
975
  ? { characters: inferredCharacters.length > 0 ? inferredCharacters : meta.characters }
964
976
  : {}),
965
- }).meta;
977
+ };
978
+ const updatedMeta = normalizeSceneMetaForPath(SYNC_DIR, scene.file_path, sourceUpdatedMeta).meta;
966
979
 
967
- writeMeta(scene.file_path, updatedMeta, { syncDir: SYNC_DIR });
980
+ writeMeta(scene.file_path, sourceUpdatedMeta, { syncDir: SYNC_DIR });
968
981
  indexSceneFile(db, SYNC_DIR, scene.file_path, updatedMeta, prose, {
969
982
  managedStructure: isManagedStructureProject(db, scene.project_id),
970
983
  });
@@ -63,6 +63,23 @@ export const WORKFLOW_CATALOGUE = [
63
63
  { tool: "get_arc", note: "Use when the thread question is really a character-progression question and character context is the better structural entry point." },
64
64
  ],
65
65
  },
66
+ {
67
+ id: "relationship_alignment",
68
+ label: "Track and repair story relationships",
69
+ use_when: "Use when the user wants to track an arc, link evidence, review stale relationships, repair metadata parity, or prepare generated recovery material for relationship metadata.",
70
+ steps: [
71
+ { tool: "find_scenes", note: "Identify scene_id and project_id from story context before recording relationships." },
72
+ { tool: "track_thread_arc", note: "Use when a scene should carry a storyline, subplot, setup, escalation, reveal, reversal, payoff, or other thread beat." },
73
+ { tool: "connect_character_place_evidence", note: "Use when a scene proves a character/place association; SQLite scene relationship indexes commit first and sidecar characters/places remain generated compatibility output." },
74
+ { tool: "record_character_relationship_beat", note: "Use when a scene proves a relationship beat between two characters without exposing the character_relationships table." },
75
+ { tool: "link_reference_evidence", note: "Use when scene, character, place, or reference evidence should point to a reference document; SQLite commits first and compatibility output is generated transparency." },
76
+ { tool: "audit_relationship_metadata", note: "Use before repair work to review stale relationship indexes and sidecar-only compatibility notes without mutating canonical state." },
77
+ { tool: "suggest_scene_references", note: "Use preview first to review candidate scene-reference links from character/place evidence; use apply only after the relationship outcome is intended." },
78
+ { tool: "enrich_scene_characters_batch", note: "Use dry_run first when prose-derived character relationship parity needs repair across multiple scenes; apply mode syncs SQLite after compatibility output and refreshes backups for changed scenes." },
79
+ { tool: "diagnose_project_backups", note: "Relationship mutation tools refresh project backups after canonical commits; run this if backup_warnings were returned or recovery readiness matters." },
80
+ { tool: "export_project_backup", note: "Prepare a deterministic recovery snapshot from SQLite canonical state; editing generated backup files does not mutate relationships." },
81
+ ],
82
+ },
66
83
  {
67
84
  id: "parity_recovery",
68
85
  label: "Recover metadata parity",
@@ -70,9 +87,11 @@ export const WORKFLOW_CATALOGUE = [
70
87
  steps: [
71
88
  { tool: "sync", note: "Refresh the index and use the result as the main signal that material has changed or parity may need attention." },
72
89
  { tool: "diagnose_structure", note: "Use when sync warning summaries suggest chapter, epigraph, folder-derived, or generated-export drift that should be understood before repair." },
90
+ { tool: "audit_relationship_metadata", note: "Use when parity questions involve stale relationship indexes, character/place sidecar notes, sidecar threads, or relationship recovery readiness." },
73
91
  { tool: "enrich_scene", note: "Use for lightweight opportunistic recovery when the current task is already touching a specific low-parity scene." },
74
92
  { tool: "enrich_scene_characters_batch", note: "Use when recovery scope is broad enough to justify focused catch-up work; prefer dry_run first." },
75
- { tool: "suggest_scene_references", note: "Use when low parity is specifically about missing scene-to-reference relationships." },
93
+ { tool: "suggest_scene_references", note: "Use when low parity is specifically about missing scene-to-reference relationships; apply mode commits SQLite first and refreshes compatibility output as generated transparency." },
94
+ { tool: "link_reference_evidence", note: "Use when the repair is a known evidence relationship rather than a suggestion-review workflow." },
76
95
  ],
77
96
  },
78
97
  {