@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 +11 -1
- package/package.json +1 -1
- package/src/index.js +8 -0
- package/src/tools/editing.js +257 -1
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.
|
|
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
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,
|
package/src/tools/editing.js
CHANGED
|
@@ -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") {
|