@hanna84/mcp-writing 3.0.0 → 3.1.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,21 @@ 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.0.0](https://github.com/hannasdev/mcp-writing.git
7
+ #### [v3.1.0](https://github.com/hannasdev/mcp-writing.git
8
+ /compare/v3.0.0...v3.1.0)
9
+
10
+ - feat(editing): enforce styleguide automatically in edit flow [`#166`](https://github.com/hannasdev/mcp-writing.git
11
+ /pull/166)
12
+
13
+ ### [v3.0.0](https://github.com/hannasdev/mcp-writing.git
8
14
  /compare/v2.18.1...v3.0.0)
9
15
 
16
+ > 2 May 2026
17
+
10
18
  - feat!: improve MCP tooling usability and harden scene-id safety [`#165`](https://github.com/hannasdev/mcp-writing.git
11
19
  /pull/165)
20
+ - Release 3.0.0 [`d7541b1`](https://github.com/hannasdev/mcp-writing.git
21
+ /commit/d7541b1511dbe92c514cb0435f94b8626be5ea29)
12
22
 
13
23
  #### [v2.18.1](https://github.com/hannasdev/mcp-writing.git
14
24
  /compare/v2.18.0...v2.18.1)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanna84/mcp-writing",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
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",
package/src/index.js CHANGED
@@ -71,6 +71,12 @@ const OWNERSHIP_GUARD_MODE_RAW = (process.env.OWNERSHIP_GUARD_MODE ?? "warn").tr
71
71
  const OWNERSHIP_GUARD_MODE = OWNERSHIP_GUARD_MODE_RAW === "fail" || OWNERSHIP_GUARD_MODE_RAW === "warn"
72
72
  ? OWNERSHIP_GUARD_MODE_RAW
73
73
  : "warn";
74
+ const STYLEGUIDE_ENFORCEMENT_MODE_RAW = (process.env.PROSE_STYLEGUIDE_ENFORCEMENT_MODE ?? "warn").trim().toLowerCase();
75
+ const STYLEGUIDE_ENFORCEMENT_MODE = STYLEGUIDE_ENFORCEMENT_MODE_RAW === "off"
76
+ || STYLEGUIDE_ENFORCEMENT_MODE_RAW === "warn"
77
+ || STYLEGUIDE_ENFORCEMENT_MODE_RAW === "required"
78
+ ? STYLEGUIDE_ENFORCEMENT_MODE_RAW
79
+ : "warn";
74
80
  const OWNERSHIP_GUARD_MODE_RAW_DISPLAY = JSON.stringify(OWNERSHIP_GUARD_MODE_RAW);
75
81
  const __filename = fileURLToPath(import.meta.url);
76
82
  const __dirname = path.dirname(__filename);
@@ -410,6 +416,7 @@ function createMcpServer() {
410
416
  isPathCandidateInsideSyncDir,
411
417
  pendingProposals,
412
418
  generateProposalId,
419
+ STYLEGUIDE_ENFORCEMENT_MODE,
413
420
  };
414
421
  registerSyncTools(s, toolContext);
415
422
  registerSearchTools(s, toolContext);
@@ -434,6 +441,7 @@ function createMcpServer() {
434
441
  permission_diagnostics: SYNC_OWNERSHIP_DIAGNOSTICS,
435
442
  git_available: GIT_AVAILABLE,
436
443
  git_enabled: GIT_ENABLED,
444
+ styleguide_enforcement_mode: STYLEGUIDE_ENFORCEMENT_MODE,
437
445
  http_port: HTTP_PORT,
438
446
  runtime_warnings: RUNTIME_DIAGNOSTICS.warnings,
439
447
  setup_recommendations: RUNTIME_DIAGNOSTICS.recommendations,
@@ -1,9 +1,17 @@
1
1
  import { z } from "zod";
2
2
  import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { createHash } from "node:crypto";
3
5
  import matter from "gray-matter";
4
6
  import yaml from "js-yaml";
5
7
  import { createSnapshot, listSnapshots } from "../core/git.js";
6
8
  import { getFileWriteDiagnostics, readMeta, indexSceneFile } from "../sync/sync.js";
9
+ import { resolveStyleguideConfig } from "../styleguide/prose-styleguide.js";
10
+ import {
11
+ PROSE_STYLEGUIDE_SKILL_BASENAME,
12
+ PROSE_STYLEGUIDE_SKILL_DIRNAME,
13
+ } from "../styleguide/prose-styleguide-skill.js";
14
+ import { analyzeSceneStyleguideDrift } from "../styleguide/prose-styleguide-drift.js";
7
15
 
8
16
  function renderSceneContent(metadata, revisedProse) {
9
17
  const hasFrontmatter = metadata && Object.keys(metadata).length > 0;
@@ -13,10 +21,197 @@ function renderSceneContent(metadata, revisedProse) {
13
21
  : `${normalizedProse}\n`;
14
22
  }
15
23
 
24
+ function digestFor(value) {
25
+ return createHash("sha256").update(value).digest("hex");
26
+ }
27
+
28
+ function buildStyleguideFingerprint({
29
+ resolvedConfig,
30
+ sources,
31
+ skillDigest,
32
+ setupRequired,
33
+ }) {
34
+ return digestFor(JSON.stringify({
35
+ resolved_config: resolvedConfig ?? null,
36
+ sources: sources ?? [],
37
+ skill_digest: skillDigest ?? null,
38
+ setup_required: Boolean(setupRequired),
39
+ }));
40
+ }
41
+
42
+ function resolveStyleguideSnapshot({ syncDir, projectId, errorResponse }) {
43
+ const resolved = resolveStyleguideConfig({
44
+ syncDir,
45
+ projectId,
46
+ });
47
+ if (!resolved.ok) {
48
+ return {
49
+ ok: false,
50
+ response: errorResponse(
51
+ resolved.error.code,
52
+ resolved.error.message,
53
+ resolved.error.details
54
+ ),
55
+ };
56
+ }
57
+
58
+ const skillPath = path.join(
59
+ syncDir,
60
+ PROSE_STYLEGUIDE_SKILL_DIRNAME,
61
+ PROSE_STYLEGUIDE_SKILL_BASENAME
62
+ );
63
+ let skillExists;
64
+ let skillDigest = null;
65
+ try {
66
+ skillExists = fs.existsSync(skillPath);
67
+ if (skillExists) {
68
+ skillDigest = digestFor(fs.readFileSync(skillPath, "utf8"));
69
+ }
70
+ } catch (error) {
71
+ const err = error instanceof Error ? error : new Error(String(error));
72
+ return {
73
+ ok: false,
74
+ response: errorResponse(
75
+ "STYLEGUIDE_SKILL_IO_ERROR",
76
+ "Failed to read skills/prose-styleguide.md while resolving styleguide snapshot.",
77
+ {
78
+ file_path: skillPath,
79
+ reason: err.message,
80
+ }
81
+ ),
82
+ };
83
+ }
84
+ const fingerprint = buildStyleguideFingerprint({
85
+ resolvedConfig: resolved.resolved_config,
86
+ sources: resolved.sources,
87
+ skillDigest,
88
+ setupRequired: resolved.setup_required,
89
+ });
90
+
91
+ return {
92
+ ok: true,
93
+ snapshot: {
94
+ project_id: projectId ?? null,
95
+ config_found: resolved.config_found,
96
+ setup_required: resolved.setup_required,
97
+ sources: resolved.sources,
98
+ resolved_config: resolved.resolved_config,
99
+ skill_file_path: skillPath,
100
+ skill_found: Boolean(skillExists),
101
+ skill_digest: skillDigest,
102
+ fingerprint,
103
+ },
104
+ };
105
+ }
106
+
107
+ function evaluateStyleguidePolicy({
108
+ snapshot,
109
+ revisedProse,
110
+ bypassStyleguide,
111
+ bypassReason,
112
+ enforcementMode,
113
+ errorResponse,
114
+ }) {
115
+ if (bypassStyleguide && !bypassReason) {
116
+ return {
117
+ ok: false,
118
+ response: errorResponse(
119
+ "STYLEGUIDE_BYPASS_REASON_REQUIRED",
120
+ "bypass_reason is required when bypass_styleguide=true."
121
+ ),
122
+ };
123
+ }
124
+
125
+ const warnings = [];
126
+ if (enforcementMode !== "off") {
127
+ if (snapshot.setup_required || !snapshot.config_found) {
128
+ if (enforcementMode === "required" && !bypassStyleguide) {
129
+ return {
130
+ ok: false,
131
+ response: errorResponse(
132
+ "STYLEGUIDE_CONFIG_REQUIRED",
133
+ "Cannot propose prose edits before prose-styleguide.config.yaml is configured.",
134
+ {
135
+ project_id: snapshot.project_id,
136
+ next_step: "Run setup_prose_styleguide_config (or bootstrap_prose_styleguide_config), then retry propose_edit.",
137
+ }
138
+ ),
139
+ };
140
+ }
141
+ warnings.push("No prose-styleguide.config.yaml is currently resolved for this scene.");
142
+ }
143
+
144
+ if (!snapshot.skill_found) {
145
+ if (enforcementMode === "required" && !bypassStyleguide) {
146
+ return {
147
+ ok: false,
148
+ response: errorResponse(
149
+ "STYLEGUIDE_SKILL_REQUIRED",
150
+ "Cannot propose prose edits before skills/prose-styleguide.md exists.",
151
+ {
152
+ next_step: "Run setup_prose_styleguide_skill, then retry propose_edit.",
153
+ }
154
+ ),
155
+ };
156
+ }
157
+ warnings.push("skills/prose-styleguide.md was not found at sync root.");
158
+ }
159
+ }
160
+
161
+ const canApply =
162
+ enforcementMode !== "off"
163
+ && !bypassStyleguide
164
+ && snapshot.config_found
165
+ && !snapshot.setup_required
166
+ && Boolean(snapshot.resolved_config);
167
+
168
+ const analysis = canApply
169
+ ? analyzeSceneStyleguideDrift({
170
+ prose: revisedProse,
171
+ resolvedConfig: snapshot.resolved_config,
172
+ })
173
+ : { observed: {}, drift: [] };
174
+
175
+ const violations = analysis.drift.map((entry) => ({
176
+ field: entry.field,
177
+ declared: entry.declared,
178
+ observed: entry.observed,
179
+ severity: "error",
180
+ }));
181
+
182
+ if (enforcementMode === "required" && violations.length > 0 && !bypassStyleguide) {
183
+ return {
184
+ ok: false,
185
+ response: errorResponse(
186
+ "STYLEGUIDE_VIOLATIONS",
187
+ "Revised prose violates required styleguide conventions.",
188
+ {
189
+ violations,
190
+ next_step: "Revise prose to match conventions or retry with bypass_styleguide=true and a bypass_reason.",
191
+ }
192
+ ),
193
+ };
194
+ }
195
+
196
+ return {
197
+ ok: true,
198
+ policy: {
199
+ enforcement_mode: enforcementMode,
200
+ bypass_used: Boolean(bypassStyleguide),
201
+ bypass_reason: bypassStyleguide ? bypassReason : null,
202
+ styleguide_applied: canApply,
203
+ warnings,
204
+ observed_signals: analysis.observed,
205
+ violations,
206
+ },
207
+ };
208
+ }
209
+
16
210
  export function registerEditingTools(s, {
17
211
  db,
18
212
  SYNC_DIR,
19
213
  GIT_ENABLED,
214
+ STYLEGUIDE_ENFORCEMENT_MODE,
20
215
  errorResponse,
21
216
  jsonResponse,
22
217
  pendingProposals,
@@ -31,8 +226,10 @@ export function registerEditingTools(s, {
31
226
  project_id: z.string().optional().describe("Optional project ID to disambiguate duplicate scene IDs across projects."),
32
227
  instruction: z.string().describe("A brief instruction for the edit (e.g. 'Tighten the opening paragraph'). Used in the git commit message."),
33
228
  revised_prose: z.string().describe("The complete revised prose text for the scene."),
229
+ bypass_styleguide: z.boolean().optional().describe("If true, bypasses automatic styleguide checks for this proposal."),
230
+ bypass_reason: z.string().optional().describe("Required when bypass_styleguide=true. Explains why this edit should ignore styleguide checks."),
34
231
  },
35
- async ({ scene_id, project_id, instruction, revised_prose }) => {
232
+ async ({ scene_id, project_id, instruction, revised_prose, bypass_styleguide = false, bypass_reason }) => {
36
233
  if (!GIT_ENABLED) {
37
234
  return errorResponse("GIT_UNAVAILABLE", "Git is not available — prose editing is not supported. Ensure git is installed and the sync directory is writable.");
38
235
  }
@@ -58,6 +255,27 @@ export function registerEditingTools(s, {
58
255
  scene = scenes[0];
59
256
  }
60
257
 
258
+ const styleguideSnapshotResult = resolveStyleguideSnapshot({
259
+ syncDir: SYNC_DIR,
260
+ projectId: scene.project_id,
261
+ errorResponse,
262
+ });
263
+ if (!styleguideSnapshotResult.ok) {
264
+ return styleguideSnapshotResult.response;
265
+ }
266
+
267
+ const styleguidePolicy = evaluateStyleguidePolicy({
268
+ snapshot: styleguideSnapshotResult.snapshot,
269
+ revisedProse: revised_prose,
270
+ bypassStyleguide: bypass_styleguide,
271
+ bypassReason: bypass_reason,
272
+ enforcementMode: STYLEGUIDE_ENFORCEMENT_MODE,
273
+ errorResponse,
274
+ });
275
+ if (!styleguidePolicy.ok) {
276
+ return styleguidePolicy.response;
277
+ }
278
+
61
279
  try {
62
280
  const raw = fs.readFileSync(scene.file_path, "utf8");
63
281
  const { data: metadata, content: currentProse } = matter(raw);
@@ -90,6 +308,8 @@ export function registerEditingTools(s, {
90
308
  rendered_content: renderedContent,
91
309
  original_prose: currentProse,
92
310
  metadata,
311
+ styleguide_snapshot: styleguideSnapshotResult.snapshot,
312
+ styleguide_policy: styleguidePolicy.policy,
93
313
  created_at: new Date().toISOString(),
94
314
  });
95
315
 
@@ -107,6 +327,13 @@ export function registerEditingTools(s, {
107
327
  instruction,
108
328
  diff_preview: diffPreview,
109
329
  noop,
330
+ styleguide: {
331
+ ...styleguidePolicy.policy,
332
+ fingerprint: styleguideSnapshotResult.snapshot.fingerprint,
333
+ config_found: styleguideSnapshotResult.snapshot.config_found,
334
+ skill_found: styleguideSnapshotResult.snapshot.skill_found,
335
+ sources: styleguideSnapshotResult.snapshot.sources,
336
+ },
110
337
  note: noop
111
338
  ? "This proposal matches the current scene file. Calling commit_edit will be a no-op."
112
339
  : "Review the diff above. Call commit_edit with this proposal_id to apply the change.",
@@ -167,6 +394,35 @@ export function registerEditingTools(s, {
167
394
  return errorResponse("INVALID_EDIT", `Proposal '${proposal_id}' is for project '${proposal.project_id}', not '${project_id}'.`);
168
395
  }
169
396
 
397
+ if (
398
+ proposal.styleguide_snapshot
399
+ && proposal.styleguide_policy
400
+ && proposal.styleguide_policy.bypass_used !== true
401
+ ) {
402
+ const latestStyleguideSnapshot = resolveStyleguideSnapshot({
403
+ syncDir: SYNC_DIR,
404
+ projectId: proposal.project_id,
405
+ errorResponse,
406
+ });
407
+ if (!latestStyleguideSnapshot.ok) {
408
+ return latestStyleguideSnapshot.response;
409
+ }
410
+
411
+ if (latestStyleguideSnapshot.snapshot.fingerprint !== proposal.styleguide_snapshot.fingerprint) {
412
+ return errorResponse(
413
+ "STYLEGUIDE_CHANGED_SINCE_PROPOSAL",
414
+ "Styleguide inputs changed after this proposal was created. Re-run propose_edit against the latest styleguide before commit.",
415
+ {
416
+ proposal_id,
417
+ project_id: proposal.project_id ?? null,
418
+ previous_fingerprint: proposal.styleguide_snapshot.fingerprint,
419
+ current_fingerprint: latestStyleguideSnapshot.snapshot.fingerprint,
420
+ next_step: "Call propose_edit again to regenerate this revision with current styleguide rules.",
421
+ }
422
+ );
423
+ }
424
+ }
425
+
170
426
  try {
171
427
  const proseWriteDiagnostics = getFileWriteDiagnostics(proposal.scene_file_path);
172
428
  if (proseWriteDiagnostics.stat_error_code === "EACCES" || proseWriteDiagnostics.stat_error_code === "EPERM") {