@hanna84/mcp-writing 3.13.1 → 3.14.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 +14 -0
- package/package.json +4 -1
- package/src/structure/structure-export.js +228 -0
- package/src/tools/sync.js +81 -0
- package/src/workflows/workflow-catalogue.js +1 -0
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.14.1](https://github.com/hannasdev/mcp-writing/compare/v3.14.0...v3.14.1)
|
|
8
|
+
|
|
9
|
+
- ci: optimize validation workflow [`#208`](https://github.com/hannasdev/mcp-writing/pull/208)
|
|
10
|
+
|
|
11
|
+
#### [v3.14.0](https://github.com/hannasdev/mcp-writing/compare/v3.13.1...v3.14.0)
|
|
12
|
+
|
|
13
|
+
> 19 May 2026
|
|
14
|
+
|
|
15
|
+
- feat: add deterministic structure exports [`#207`](https://github.com/hannasdev/mcp-writing/pull/207)
|
|
16
|
+
- Release 3.14.0 [`94fc46b`](https://github.com/hannasdev/mcp-writing/commit/94fc46bc98996688479a2bee9fc73415f69c3a86)
|
|
17
|
+
|
|
7
18
|
#### [v3.13.1](https://github.com/hannasdev/mcp-writing/compare/v3.13.0...v3.13.1)
|
|
8
19
|
|
|
20
|
+
> 19 May 2026
|
|
21
|
+
|
|
9
22
|
- docs(product): mark M7 complete [`#206`](https://github.com/hannasdev/mcp-writing/pull/206)
|
|
23
|
+
- Release 3.13.1 [`6f984b9`](https://github.com/hannasdev/mcp-writing/commit/6f984b9d53ad8187749b4038338e380704e398c6)
|
|
10
24
|
|
|
11
25
|
#### [v3.13.0](https://github.com/hannasdev/mcp-writing/compare/v3.12.0...v3.13.0)
|
|
12
26
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hanna84/mcp-writing",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.14.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",
|
|
@@ -74,6 +74,9 @@
|
|
|
74
74
|
"lint": "eslint *.js src/index.js src/core src/review-bundles src/runtime src/setup src/scripts src/structure src/styleguide src/sync src/tools src/workflows src/world",
|
|
75
75
|
"guard:legacy-root-imports": "node src/scripts/check-legacy-root-imports.mjs",
|
|
76
76
|
"docs": "node src/scripts/generate-tool-docs.mjs",
|
|
77
|
+
"check:docs": "npm run docs && git diff --exit-code -- docs/agents/tools.md",
|
|
78
|
+
"check:static": "npm run lint && npm run guard:legacy-root-imports && npm run check:docs",
|
|
79
|
+
"check:pr": "npm run check:static && npm test",
|
|
77
80
|
"lint:metadata": "node src/scripts/lint-metadata.mjs",
|
|
78
81
|
"sync:server-json-version": "node src/scripts/sync-server-json-version.mjs",
|
|
79
82
|
"test:unit": "node --experimental-sqlite --test src/test/unit/*.test.mjs",
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
export const STRUCTURE_EXPORT_SCHEMA_VERSION = 1;
|
|
6
|
+
|
|
7
|
+
function normalizePathForExport(syncDir, filePath) {
|
|
8
|
+
if (!filePath) return null;
|
|
9
|
+
const syncRoot = path.resolve(syncDir);
|
|
10
|
+
const resolvedPath = path.isAbsolute(filePath)
|
|
11
|
+
? path.resolve(filePath)
|
|
12
|
+
: path.resolve(syncRoot, filePath);
|
|
13
|
+
const normalized = path.relative(syncRoot, resolvedPath);
|
|
14
|
+
if (normalized.startsWith("..") || path.isAbsolute(normalized)) {
|
|
15
|
+
throw new Error(`Cannot export path outside sync_dir: ${filePath}`);
|
|
16
|
+
}
|
|
17
|
+
return normalized.split(path.sep).join("/");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function stableStringify(value, indent = 2) {
|
|
21
|
+
const seen = new WeakSet();
|
|
22
|
+
function normalize(input) {
|
|
23
|
+
if (input === null || typeof input !== "object") return input;
|
|
24
|
+
if (seen.has(input)) {
|
|
25
|
+
throw new TypeError("Cannot stable-stringify circular structure.");
|
|
26
|
+
}
|
|
27
|
+
seen.add(input);
|
|
28
|
+
if (Array.isArray(input)) {
|
|
29
|
+
const array = input.map(normalize);
|
|
30
|
+
seen.delete(input);
|
|
31
|
+
return array;
|
|
32
|
+
}
|
|
33
|
+
const object = {};
|
|
34
|
+
for (const key of Object.keys(input).sort()) {
|
|
35
|
+
object[key] = normalize(input[key]);
|
|
36
|
+
}
|
|
37
|
+
seen.delete(input);
|
|
38
|
+
return object;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return JSON.stringify(normalize(value), null, indent);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function sha256(value) {
|
|
45
|
+
return crypto.createHash("sha256").update(value).digest("hex");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function defaultStructureExportFileName(projectId) {
|
|
49
|
+
const slug = String(projectId ?? "project")
|
|
50
|
+
.toLowerCase()
|
|
51
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
52
|
+
.replace(/^-+|-+$/g, "") || "project";
|
|
53
|
+
return `${slug}.structure.json`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function buildStructureExport(db, { projectId, syncDir }) {
|
|
57
|
+
const project = db.prepare(`
|
|
58
|
+
SELECT project_id, universe_id, name
|
|
59
|
+
FROM projects
|
|
60
|
+
WHERE project_id = ?
|
|
61
|
+
`).get(projectId);
|
|
62
|
+
if (!project) {
|
|
63
|
+
return {
|
|
64
|
+
ok: false,
|
|
65
|
+
error: {
|
|
66
|
+
code: "NOT_FOUND",
|
|
67
|
+
message: `Project '${projectId}' not found.`,
|
|
68
|
+
details: { project_id: projectId },
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const chapters = db.prepare(`
|
|
74
|
+
SELECT chapter_id, title, sort_index, logline, source_path, source_checksum, metadata_stale, updated_at
|
|
75
|
+
FROM chapters
|
|
76
|
+
WHERE project_id = ?
|
|
77
|
+
ORDER BY sort_index, chapter_id
|
|
78
|
+
`).all(projectId).map(row => ({
|
|
79
|
+
chapter_id: row.chapter_id,
|
|
80
|
+
title: row.title,
|
|
81
|
+
sort_index: row.sort_index,
|
|
82
|
+
logline: row.logline ?? null,
|
|
83
|
+
source_path: normalizePathForExport(syncDir, row.source_path),
|
|
84
|
+
source_checksum: row.source_checksum ?? null,
|
|
85
|
+
metadata_stale: row.metadata_stale,
|
|
86
|
+
updated_at: row.updated_at,
|
|
87
|
+
}));
|
|
88
|
+
|
|
89
|
+
const scenes = db.prepare(`
|
|
90
|
+
SELECT
|
|
91
|
+
s.scene_id,
|
|
92
|
+
s.title,
|
|
93
|
+
s.chapter_id,
|
|
94
|
+
s.scene_role,
|
|
95
|
+
s.part,
|
|
96
|
+
s.chapter,
|
|
97
|
+
s.chapter_title,
|
|
98
|
+
s.timeline_position,
|
|
99
|
+
s.file_path,
|
|
100
|
+
s.prose_checksum,
|
|
101
|
+
s.metadata_stale,
|
|
102
|
+
s.updated_at
|
|
103
|
+
FROM scenes s
|
|
104
|
+
LEFT JOIN chapters c
|
|
105
|
+
ON c.project_id = s.project_id
|
|
106
|
+
AND c.chapter_id = s.chapter_id
|
|
107
|
+
WHERE s.project_id = ?
|
|
108
|
+
ORDER BY
|
|
109
|
+
CASE WHEN c.sort_index IS NULL THEN 2147483647 ELSE c.sort_index END,
|
|
110
|
+
CASE WHEN s.timeline_position IS NULL THEN 1 ELSE 0 END,
|
|
111
|
+
s.timeline_position,
|
|
112
|
+
s.scene_id
|
|
113
|
+
`).all(projectId).map(row => ({
|
|
114
|
+
scene_id: row.scene_id,
|
|
115
|
+
title: row.title ?? null,
|
|
116
|
+
chapter_id: row.chapter_id ?? null,
|
|
117
|
+
scene_role: row.scene_role ?? null,
|
|
118
|
+
part: row.part ?? null,
|
|
119
|
+
compatibility_chapter: row.chapter ?? null,
|
|
120
|
+
compatibility_chapter_title: row.chapter_title ?? null,
|
|
121
|
+
timeline_position: row.timeline_position ?? null,
|
|
122
|
+
file_path: normalizePathForExport(syncDir, row.file_path),
|
|
123
|
+
prose_checksum: row.prose_checksum ?? null,
|
|
124
|
+
metadata_stale: row.metadata_stale,
|
|
125
|
+
updated_at: row.updated_at,
|
|
126
|
+
}));
|
|
127
|
+
|
|
128
|
+
const epigraphs = db.prepare(`
|
|
129
|
+
SELECT
|
|
130
|
+
e.epigraph_id,
|
|
131
|
+
e.chapter_id,
|
|
132
|
+
e.file_path,
|
|
133
|
+
e.prose_checksum,
|
|
134
|
+
e.metadata_stale,
|
|
135
|
+
e.updated_at
|
|
136
|
+
FROM epigraphs e
|
|
137
|
+
LEFT JOIN chapters c
|
|
138
|
+
ON c.project_id = e.project_id
|
|
139
|
+
AND c.chapter_id = e.chapter_id
|
|
140
|
+
WHERE e.project_id = ?
|
|
141
|
+
ORDER BY
|
|
142
|
+
CASE WHEN c.sort_index IS NULL THEN 2147483647 ELSE c.sort_index END,
|
|
143
|
+
e.epigraph_id
|
|
144
|
+
`).all(projectId).map(row => ({
|
|
145
|
+
epigraph_id: row.epigraph_id,
|
|
146
|
+
chapter_id: row.chapter_id,
|
|
147
|
+
file_path: normalizePathForExport(syncDir, row.file_path),
|
|
148
|
+
prose_checksum: row.prose_checksum ?? null,
|
|
149
|
+
metadata_stale: row.metadata_stale,
|
|
150
|
+
updated_at: row.updated_at,
|
|
151
|
+
}));
|
|
152
|
+
|
|
153
|
+
const baseSnapshot = {
|
|
154
|
+
export: {
|
|
155
|
+
schema_version: STRUCTURE_EXPORT_SCHEMA_VERSION,
|
|
156
|
+
canonical_source: "sqlite",
|
|
157
|
+
project_id: project.project_id,
|
|
158
|
+
generated_transparency: true,
|
|
159
|
+
mutation_surface: false,
|
|
160
|
+
},
|
|
161
|
+
project: {
|
|
162
|
+
project_id: project.project_id,
|
|
163
|
+
universe_id: project.universe_id ?? null,
|
|
164
|
+
name: project.name,
|
|
165
|
+
},
|
|
166
|
+
summary: {
|
|
167
|
+
chapter_count: chapters.length,
|
|
168
|
+
scene_count: scenes.length,
|
|
169
|
+
epigraph_count: epigraphs.length,
|
|
170
|
+
},
|
|
171
|
+
chapters,
|
|
172
|
+
scenes,
|
|
173
|
+
epigraphs,
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const structureChecksum = sha256(stableStringify(baseSnapshot, 0));
|
|
177
|
+
return {
|
|
178
|
+
ok: true,
|
|
179
|
+
snapshot: {
|
|
180
|
+
...baseSnapshot,
|
|
181
|
+
export: {
|
|
182
|
+
...baseSnapshot.export,
|
|
183
|
+
structure_checksum: structureChecksum,
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function renderStructureExport(snapshot) {
|
|
190
|
+
return `${stableStringify(snapshot, 2)}\n`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function writeStructureExportFile(snapshot, { outputDir, fileName }) {
|
|
194
|
+
const normalizedOutputDir = path.resolve(outputDir);
|
|
195
|
+
const targetPath = path.resolve(normalizedOutputDir, fileName);
|
|
196
|
+
const relative = path.relative(normalizedOutputDir, targetPath);
|
|
197
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
198
|
+
throw new Error(`Output file '${fileName}' resolves outside output_dir.`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (!fs.existsSync(normalizedOutputDir)) {
|
|
202
|
+
fs.mkdirSync(normalizedOutputDir, { recursive: true });
|
|
203
|
+
} else {
|
|
204
|
+
const outputDirStat = fs.lstatSync(normalizedOutputDir);
|
|
205
|
+
if (!outputDirStat.isDirectory()) {
|
|
206
|
+
throw new Error(`output_dir exists but is not a directory: ${normalizedOutputDir}`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
fs.accessSync(normalizedOutputDir, fs.constants.W_OK);
|
|
210
|
+
|
|
211
|
+
const stat = (() => {
|
|
212
|
+
try {
|
|
213
|
+
return fs.lstatSync(targetPath);
|
|
214
|
+
} catch (error) {
|
|
215
|
+
if (error?.code === "ENOENT") return null;
|
|
216
|
+
throw error;
|
|
217
|
+
}
|
|
218
|
+
})();
|
|
219
|
+
if (stat?.isSymbolicLink()) {
|
|
220
|
+
throw new Error(`Refusing to write: target path is a symlink: ${targetPath}`);
|
|
221
|
+
}
|
|
222
|
+
if (stat && !stat.isFile()) {
|
|
223
|
+
throw new Error(`Refusing to write: target path exists but is not a regular file: ${targetPath}`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
fs.writeFileSync(targetPath, renderStructureExport(snapshot), "utf8");
|
|
227
|
+
return targetPath;
|
|
228
|
+
}
|
package/src/tools/sync.js
CHANGED
|
@@ -5,6 +5,11 @@ import matter from "gray-matter";
|
|
|
5
5
|
import { syncAll, writeMeta, readMeta, indexSceneFile, normalizeSceneMetaForPath } from "../sync/sync.js";
|
|
6
6
|
import { importScrivenerSync, validateProjectId } from "../sync/importer.js";
|
|
7
7
|
import { runStructureDiagnostics } from "../structure/structure-diagnostics.js";
|
|
8
|
+
import {
|
|
9
|
+
buildStructureExport,
|
|
10
|
+
defaultStructureExportFileName,
|
|
11
|
+
writeStructureExportFile,
|
|
12
|
+
} from "../structure/structure-export.js";
|
|
8
13
|
|
|
9
14
|
export function registerSyncTools(s, {
|
|
10
15
|
db,
|
|
@@ -23,6 +28,7 @@ export function registerSyncTools(s, {
|
|
|
23
28
|
resolveBatchTargetScenes,
|
|
24
29
|
maxScenesNextStep,
|
|
25
30
|
isPathInsideSyncDir,
|
|
31
|
+
resolveOutputDirWithinSync,
|
|
26
32
|
deriveLoglineFromProse,
|
|
27
33
|
inferCharacterIdsFromProse,
|
|
28
34
|
}) {
|
|
@@ -67,6 +73,81 @@ export function registerSyncTools(s, {
|
|
|
67
73
|
}
|
|
68
74
|
);
|
|
69
75
|
|
|
76
|
+
s.tool(
|
|
77
|
+
"export_structure_snapshot",
|
|
78
|
+
"Generate a deterministic JSON structure export from SQLite canonical state for Git review and future explicit recovery workflows. The export is generated transparency only: editing it does not change canonical state.",
|
|
79
|
+
{
|
|
80
|
+
project_id: z.string().describe("Project ID to export (e.g. 'test-novel')."),
|
|
81
|
+
output_dir: z.string().optional().describe("Directory under WRITING_SYNC_DIR where the export JSON should be written. Defaults to structure-exports."),
|
|
82
|
+
},
|
|
83
|
+
async ({ project_id, output_dir }) => {
|
|
84
|
+
if (!SYNC_DIR_WRITABLE) {
|
|
85
|
+
return errorResponse("READ_ONLY", "Cannot export structure snapshot: sync dir is read-only.");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const projectIdCheck = validateProjectId(project_id);
|
|
89
|
+
if (!projectIdCheck.ok) {
|
|
90
|
+
return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const requestedOutputDir = output_dir
|
|
95
|
+
? (path.isAbsolute(output_dir) ? output_dir : path.join(SYNC_DIR_ABS, output_dir))
|
|
96
|
+
: path.join(SYNC_DIR_ABS, "structure-exports");
|
|
97
|
+
const { resolvedOutputDir, relativeToSyncDir } = resolveOutputDirWithinSync(requestedOutputDir);
|
|
98
|
+
const outputDirSegments = relativeToSyncDir
|
|
99
|
+
.split(path.sep)
|
|
100
|
+
.filter(Boolean)
|
|
101
|
+
.map(segment => segment.toLowerCase());
|
|
102
|
+
if (outputDirSegments.includes("scenes")) {
|
|
103
|
+
return errorResponse(
|
|
104
|
+
"INVALID_OUTPUT_DIR",
|
|
105
|
+
"output_dir cannot be inside a scenes directory. Choose a dedicated generated export folder under WRITING_SYNC_DIR.",
|
|
106
|
+
{ output_dir: resolvedOutputDir }
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const built = buildStructureExport(db, {
|
|
111
|
+
projectId: project_id,
|
|
112
|
+
syncDir: SYNC_DIR_ABS,
|
|
113
|
+
});
|
|
114
|
+
if (!built.ok) {
|
|
115
|
+
return errorResponse(built.error.code, built.error.message, built.error.details);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const fileName = defaultStructureExportFileName(project_id);
|
|
119
|
+
const outputPath = writeStructureExportFile(built.snapshot, {
|
|
120
|
+
outputDir: resolvedOutputDir,
|
|
121
|
+
fileName,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
return jsonResponse({
|
|
125
|
+
ok: true,
|
|
126
|
+
action: "exported",
|
|
127
|
+
project_id,
|
|
128
|
+
output_path: outputPath,
|
|
129
|
+
relative_output_path: path.join(relativeToSyncDir, fileName).split(path.sep).join("/"),
|
|
130
|
+
export: built.snapshot.export,
|
|
131
|
+
summary: built.snapshot.summary,
|
|
132
|
+
next_step: "Review or commit the generated structure export. Do not edit it as a mutation surface; use explicit structure tools for canonical changes.",
|
|
133
|
+
});
|
|
134
|
+
} catch (error) {
|
|
135
|
+
if (
|
|
136
|
+
error &&
|
|
137
|
+
typeof error === "object" &&
|
|
138
|
+
error.name === "CoreValidationError" &&
|
|
139
|
+
typeof error.code === "string"
|
|
140
|
+
) {
|
|
141
|
+
return errorResponse(error.code, error.message ?? "Request failed.", error.details);
|
|
142
|
+
}
|
|
143
|
+
return errorResponse(
|
|
144
|
+
"EXPORT_STRUCTURE_FAILED",
|
|
145
|
+
error instanceof Error ? error.message : "Failed to export structure snapshot."
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
);
|
|
150
|
+
|
|
70
151
|
s.tool(
|
|
71
152
|
"import_scrivener_sync",
|
|
72
153
|
"[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().",
|
|
@@ -89,6 +89,7 @@ export const WORKFLOW_CATALOGUE = [
|
|
|
89
89
|
{ tool: "move_scene", note: "Use when a scene should move to another canonical chapter and/or unused timeline_position; this does not move the scene source file." },
|
|
90
90
|
{ tool: "assign_scene_to_chapter", note: "Use this named structure workflow for chapter assignment or clearing instead of editing chapter fields through generic metadata updates." },
|
|
91
91
|
{ tool: "diagnose_structure", note: "Run when the assignment is part of a drift repair workflow or when folder-derived structure may disagree with the requested link." },
|
|
92
|
+
{ tool: "export_structure_snapshot", note: "Generate a deterministic SQLite-derived structure export for Git review after canonical structure changes; editing the export does not mutate structure." },
|
|
92
93
|
],
|
|
93
94
|
},
|
|
94
95
|
{
|