@hanna84/mcp-writing 2.2.0 → 2.4.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 +20 -0
- package/index.js +414 -0
- package/package.json +3 -1
- package/prose-styleguide-drift.js +125 -0
- package/prose-styleguide-skill.js +125 -0
- package/prose-styleguide.js +185 -0
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
|
+
#### [v2.4.0](https://github.com/hannasdev/mcp-writing.git
|
|
8
|
+
/compare/v2.3.0...v2.4.0)
|
|
9
|
+
|
|
10
|
+
- feat(styleguide): add config update preview and prose drift checks [`#86`](https://github.com/hannasdev/mcp-writing.git
|
|
11
|
+
/pull/86)
|
|
12
|
+
|
|
13
|
+
#### [v2.3.0](https://github.com/hannasdev/mcp-writing.git
|
|
14
|
+
/compare/v2.2.0...v2.3.0)
|
|
15
|
+
|
|
16
|
+
> 26 April 2026
|
|
17
|
+
|
|
18
|
+
- feat(styleguide): generate prose styleguide skill from resolved config [`#85`](https://github.com/hannasdev/mcp-writing.git
|
|
19
|
+
/pull/85)
|
|
20
|
+
- Release 2.3.0 [`3490851`](https://github.com/hannasdev/mcp-writing.git
|
|
21
|
+
/commit/3490851ccc94989c4a487790b4b35c6a15fac860)
|
|
22
|
+
|
|
7
23
|
#### [v2.2.0](https://github.com/hannasdev/mcp-writing.git
|
|
8
24
|
/compare/v2.1.0...v2.2.0)
|
|
9
25
|
|
|
26
|
+
> 25 April 2026
|
|
27
|
+
|
|
10
28
|
- feat(styleguide): add prose styleguide config resolution and MCP tools [`#84`](https://github.com/hannasdev/mcp-writing.git
|
|
11
29
|
/pull/84)
|
|
30
|
+
- Release 2.2.0 [`58ecb48`](https://github.com/hannasdev/mcp-writing.git
|
|
31
|
+
/commit/58ecb480200498459e3d8876c06554d8a26c2fb5)
|
|
12
32
|
|
|
13
33
|
#### [v2.1.0](https://github.com/hannasdev/mcp-writing.git
|
|
14
34
|
/compare/v2.0.4...v2.1.0)
|
package/index.js
CHANGED
|
@@ -21,8 +21,20 @@ import {
|
|
|
21
21
|
STYLEGUIDE_CONFIG_BASENAME,
|
|
22
22
|
STYLEGUIDE_ENUMS,
|
|
23
23
|
buildStyleguideConfigDraft,
|
|
24
|
+
previewStyleguideConfigUpdate,
|
|
24
25
|
resolveStyleguideConfig,
|
|
26
|
+
summarizeStyleguideConfig,
|
|
27
|
+
updateStyleguideConfig,
|
|
25
28
|
} from "./prose-styleguide.js";
|
|
29
|
+
import {
|
|
30
|
+
analyzeSceneStyleguideDrift,
|
|
31
|
+
suggestStyleguideUpdatesFromScenes,
|
|
32
|
+
} from "./prose-styleguide-drift.js";
|
|
33
|
+
import {
|
|
34
|
+
PROSE_STYLEGUIDE_SKILL_BASENAME,
|
|
35
|
+
PROSE_STYLEGUIDE_SKILL_DIRNAME,
|
|
36
|
+
buildProseStyleguideSkill,
|
|
37
|
+
} from "./prose-styleguide-skill.js";
|
|
26
38
|
import {
|
|
27
39
|
REVIEW_BUNDLE_PROFILES,
|
|
28
40
|
REVIEW_BUNDLE_STRICTNESS,
|
|
@@ -1513,6 +1525,408 @@ function createMcpServer() {
|
|
|
1513
1525
|
}
|
|
1514
1526
|
);
|
|
1515
1527
|
|
|
1528
|
+
s.tool(
|
|
1529
|
+
"summarize_prose_styleguide_config",
|
|
1530
|
+
"Summarize the currently resolved prose styleguide config in plain language for review or confirmation.",
|
|
1531
|
+
{
|
|
1532
|
+
project_id: z.string().optional().describe("Optional project ID for project-scoped resolution (e.g. 'the-lamb' or 'universe-1/book-1')."),
|
|
1533
|
+
},
|
|
1534
|
+
async ({ project_id }) => {
|
|
1535
|
+
if (project_id !== undefined) {
|
|
1536
|
+
const projectIdCheck = validateProjectId(project_id);
|
|
1537
|
+
if (!projectIdCheck.ok) {
|
|
1538
|
+
return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
const resolved = resolveStyleguideConfig({
|
|
1543
|
+
syncDir: SYNC_DIR,
|
|
1544
|
+
projectId: project_id,
|
|
1545
|
+
});
|
|
1546
|
+
if (!resolved.ok) {
|
|
1547
|
+
return errorResponse(
|
|
1548
|
+
resolved.error.code,
|
|
1549
|
+
resolved.error.message,
|
|
1550
|
+
resolved.error.details
|
|
1551
|
+
);
|
|
1552
|
+
}
|
|
1553
|
+
if (resolved.setup_required || !resolved.resolved_config) {
|
|
1554
|
+
return errorResponse(
|
|
1555
|
+
"STYLEGUIDE_CONFIG_REQUIRED",
|
|
1556
|
+
"Cannot summarize prose styleguide config before prose-styleguide.config.yaml is set up.",
|
|
1557
|
+
{ project_id: project_id ?? null }
|
|
1558
|
+
);
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
const summary = summarizeStyleguideConfig({
|
|
1562
|
+
resolvedConfig: resolved.resolved_config,
|
|
1563
|
+
inferredDefaults: resolved.inferred_defaults,
|
|
1564
|
+
});
|
|
1565
|
+
if (!summary.ok) {
|
|
1566
|
+
return errorResponse(summary.error.code, summary.error.message);
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
return jsonResponse({
|
|
1570
|
+
ok: true,
|
|
1571
|
+
project_id: project_id ?? null,
|
|
1572
|
+
summary_text: summary.summary_text,
|
|
1573
|
+
summary_lines: summary.summary_lines,
|
|
1574
|
+
styleguide: resolved,
|
|
1575
|
+
});
|
|
1576
|
+
}
|
|
1577
|
+
);
|
|
1578
|
+
|
|
1579
|
+
s.tool(
|
|
1580
|
+
"update_prose_styleguide_config",
|
|
1581
|
+
"Update an existing prose-styleguide.config.yaml at sync-root or project-root scope by writing only explicit field changes.",
|
|
1582
|
+
{
|
|
1583
|
+
scope: z.enum(["sync_root", "project_root"]).describe("Config scope to update."),
|
|
1584
|
+
project_id: z.string().optional().describe("Project ID when updating project_root config (e.g. 'the-lamb' or 'universe-1/book-1')."),
|
|
1585
|
+
updates: z.object({
|
|
1586
|
+
language: z.enum(STYLEGUIDE_ENUMS.language).optional(),
|
|
1587
|
+
spelling: z.enum(STYLEGUIDE_ENUMS.spelling).optional(),
|
|
1588
|
+
quotation_style: z.enum(STYLEGUIDE_ENUMS.quotation_style).optional(),
|
|
1589
|
+
quotation_style_nested: z.enum(STYLEGUIDE_ENUMS.quotation_style_nested).optional(),
|
|
1590
|
+
em_dash_spacing: z.enum(STYLEGUIDE_ENUMS.em_dash_spacing).optional(),
|
|
1591
|
+
ellipsis_style: z.enum(STYLEGUIDE_ENUMS.ellipsis_style).optional(),
|
|
1592
|
+
abbreviation_periods: z.enum(STYLEGUIDE_ENUMS.abbreviation_periods).optional(),
|
|
1593
|
+
oxford_comma: z.enum(STYLEGUIDE_ENUMS.oxford_comma).optional(),
|
|
1594
|
+
numbers: z.enum(STYLEGUIDE_ENUMS.numbers).optional(),
|
|
1595
|
+
date_format: z.enum(STYLEGUIDE_ENUMS.date_format).optional(),
|
|
1596
|
+
time_format: z.enum(STYLEGUIDE_ENUMS.time_format).optional(),
|
|
1597
|
+
tense: z.string().optional(),
|
|
1598
|
+
pov: z.enum(STYLEGUIDE_ENUMS.pov).optional(),
|
|
1599
|
+
dialogue_tags: z.enum(STYLEGUIDE_ENUMS.dialogue_tags).optional(),
|
|
1600
|
+
sentence_fragments: z.enum(STYLEGUIDE_ENUMS.sentence_fragments).optional(),
|
|
1601
|
+
voice_notes: z.string().optional(),
|
|
1602
|
+
}).strict().describe("Explicit config field changes to write at the selected scope."),
|
|
1603
|
+
},
|
|
1604
|
+
async ({ scope, project_id, updates }) => {
|
|
1605
|
+
if (project_id !== undefined) {
|
|
1606
|
+
const projectIdCheck = validateProjectId(project_id);
|
|
1607
|
+
if (!projectIdCheck.ok) {
|
|
1608
|
+
return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
if (scope === "project_root" && !project_id) {
|
|
1613
|
+
return errorResponse(
|
|
1614
|
+
"PROJECT_ID_REQUIRED",
|
|
1615
|
+
"project_id is required when scope=project_root."
|
|
1616
|
+
);
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
if (!SYNC_DIR_WRITABLE) {
|
|
1620
|
+
return errorResponse(
|
|
1621
|
+
"SYNC_DIR_NOT_WRITABLE",
|
|
1622
|
+
"Cannot update styleguide config because WRITING_SYNC_DIR is not writable in this runtime.",
|
|
1623
|
+
{ sync_dir: SYNC_DIR_ABS }
|
|
1624
|
+
);
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
const updated = updateStyleguideConfig({
|
|
1628
|
+
syncDir: SYNC_DIR,
|
|
1629
|
+
scope,
|
|
1630
|
+
projectId: project_id,
|
|
1631
|
+
updates,
|
|
1632
|
+
});
|
|
1633
|
+
if (!updated.ok) {
|
|
1634
|
+
return errorResponse(
|
|
1635
|
+
updated.error.code,
|
|
1636
|
+
updated.error.message,
|
|
1637
|
+
updated.error.details
|
|
1638
|
+
);
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
return jsonResponse({
|
|
1642
|
+
ok: true,
|
|
1643
|
+
scope: updated.scope,
|
|
1644
|
+
project_id: updated.project_id,
|
|
1645
|
+
file_path: path.resolve(updated.file_path),
|
|
1646
|
+
config: updated.config,
|
|
1647
|
+
changed_fields: updated.changed_fields,
|
|
1648
|
+
noop: Boolean(updated.noop),
|
|
1649
|
+
message: updated.message,
|
|
1650
|
+
warnings: updated.warnings,
|
|
1651
|
+
});
|
|
1652
|
+
}
|
|
1653
|
+
);
|
|
1654
|
+
|
|
1655
|
+
s.tool(
|
|
1656
|
+
"preview_prose_styleguide_config_update",
|
|
1657
|
+
"Preview how explicit updates would change an existing prose-styleguide.config.yaml without writing any files.",
|
|
1658
|
+
{
|
|
1659
|
+
scope: z.enum(["sync_root", "project_root"]).describe("Config scope to preview updates for."),
|
|
1660
|
+
project_id: z.string().optional().describe("Project ID when previewing project_root config updates (e.g. 'the-lamb' or 'universe-1/book-1')."),
|
|
1661
|
+
updates: z.object({
|
|
1662
|
+
language: z.enum(STYLEGUIDE_ENUMS.language).optional(),
|
|
1663
|
+
spelling: z.enum(STYLEGUIDE_ENUMS.spelling).optional(),
|
|
1664
|
+
quotation_style: z.enum(STYLEGUIDE_ENUMS.quotation_style).optional(),
|
|
1665
|
+
quotation_style_nested: z.enum(STYLEGUIDE_ENUMS.quotation_style_nested).optional(),
|
|
1666
|
+
em_dash_spacing: z.enum(STYLEGUIDE_ENUMS.em_dash_spacing).optional(),
|
|
1667
|
+
ellipsis_style: z.enum(STYLEGUIDE_ENUMS.ellipsis_style).optional(),
|
|
1668
|
+
abbreviation_periods: z.enum(STYLEGUIDE_ENUMS.abbreviation_periods).optional(),
|
|
1669
|
+
oxford_comma: z.enum(STYLEGUIDE_ENUMS.oxford_comma).optional(),
|
|
1670
|
+
numbers: z.enum(STYLEGUIDE_ENUMS.numbers).optional(),
|
|
1671
|
+
date_format: z.enum(STYLEGUIDE_ENUMS.date_format).optional(),
|
|
1672
|
+
time_format: z.enum(STYLEGUIDE_ENUMS.time_format).optional(),
|
|
1673
|
+
tense: z.string().optional(),
|
|
1674
|
+
pov: z.enum(STYLEGUIDE_ENUMS.pov).optional(),
|
|
1675
|
+
dialogue_tags: z.enum(STYLEGUIDE_ENUMS.dialogue_tags).optional(),
|
|
1676
|
+
sentence_fragments: z.enum(STYLEGUIDE_ENUMS.sentence_fragments).optional(),
|
|
1677
|
+
voice_notes: z.string().optional(),
|
|
1678
|
+
}).strict().describe("Explicit config field changes to preview at the selected scope."),
|
|
1679
|
+
},
|
|
1680
|
+
async ({ scope, project_id, updates }) => {
|
|
1681
|
+
if (project_id !== undefined) {
|
|
1682
|
+
const projectIdCheck = validateProjectId(project_id);
|
|
1683
|
+
if (!projectIdCheck.ok) {
|
|
1684
|
+
return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
if (scope === "project_root" && !project_id) {
|
|
1689
|
+
return errorResponse(
|
|
1690
|
+
"PROJECT_ID_REQUIRED",
|
|
1691
|
+
"project_id is required when scope=project_root."
|
|
1692
|
+
);
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
const preview = previewStyleguideConfigUpdate({
|
|
1696
|
+
syncDir: SYNC_DIR,
|
|
1697
|
+
scope,
|
|
1698
|
+
projectId: project_id,
|
|
1699
|
+
updates,
|
|
1700
|
+
});
|
|
1701
|
+
if (!preview.ok) {
|
|
1702
|
+
return errorResponse(
|
|
1703
|
+
preview.error.code,
|
|
1704
|
+
preview.error.message,
|
|
1705
|
+
preview.error.details
|
|
1706
|
+
);
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
return jsonResponse({
|
|
1710
|
+
ok: true,
|
|
1711
|
+
scope: preview.scope,
|
|
1712
|
+
project_id: preview.project_id,
|
|
1713
|
+
file_path: path.resolve(preview.file_path),
|
|
1714
|
+
current_config: preview.current_config,
|
|
1715
|
+
next_config: preview.config,
|
|
1716
|
+
changed_fields: preview.changed_fields,
|
|
1717
|
+
noop: preview.changed_fields.length === 0,
|
|
1718
|
+
message: preview.changed_fields.length === 0
|
|
1719
|
+
? "No changes detected for requested styleguide updates."
|
|
1720
|
+
: "Preview generated.",
|
|
1721
|
+
warnings: preview.warnings,
|
|
1722
|
+
});
|
|
1723
|
+
}
|
|
1724
|
+
);
|
|
1725
|
+
|
|
1726
|
+
s.tool(
|
|
1727
|
+
"check_prose_styleguide_drift",
|
|
1728
|
+
"Detect styleguide drift by comparing declared config conventions against observed signals in scene prose.",
|
|
1729
|
+
{
|
|
1730
|
+
project_id: z.string().describe("Project ID to analyze (e.g. 'the-lamb' or 'universe-1/book-1')."),
|
|
1731
|
+
scene_ids: z.array(z.string()).optional().describe("Optional scene_id allowlist to analyze."),
|
|
1732
|
+
part: z.number().int().optional().describe("Optional part filter."),
|
|
1733
|
+
chapter: z.number().int().optional().describe("Optional chapter filter."),
|
|
1734
|
+
max_scenes: z.number().int().positive().optional().describe("Maximum number of scenes to analyze (default: 50)."),
|
|
1735
|
+
min_agreement: z.number().min(0).max(1).optional().describe("Minimum agreement ratio for suggested updates (default: 0.6)."),
|
|
1736
|
+
include_clean_scenes: z.boolean().optional().describe("If true, include scenes with no detected drift in scene_results."),
|
|
1737
|
+
},
|
|
1738
|
+
async ({
|
|
1739
|
+
project_id,
|
|
1740
|
+
scene_ids,
|
|
1741
|
+
part,
|
|
1742
|
+
chapter,
|
|
1743
|
+
max_scenes = 50,
|
|
1744
|
+
min_agreement = 0.6,
|
|
1745
|
+
include_clean_scenes = false,
|
|
1746
|
+
}) => {
|
|
1747
|
+
const projectIdCheck = validateProjectId(project_id);
|
|
1748
|
+
if (!projectIdCheck.ok) {
|
|
1749
|
+
return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
const resolved = resolveStyleguideConfig({
|
|
1753
|
+
syncDir: SYNC_DIR,
|
|
1754
|
+
projectId: project_id,
|
|
1755
|
+
});
|
|
1756
|
+
if (!resolved.ok) {
|
|
1757
|
+
return errorResponse(
|
|
1758
|
+
resolved.error.code,
|
|
1759
|
+
resolved.error.message,
|
|
1760
|
+
resolved.error.details
|
|
1761
|
+
);
|
|
1762
|
+
}
|
|
1763
|
+
if (resolved.setup_required || !resolved.resolved_config) {
|
|
1764
|
+
return errorResponse(
|
|
1765
|
+
"STYLEGUIDE_CONFIG_REQUIRED",
|
|
1766
|
+
"Cannot check prose styleguide drift before prose-styleguide.config.yaml is set up.",
|
|
1767
|
+
{ project_id }
|
|
1768
|
+
);
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
const targetResolution = resolveBatchTargetScenes(db, {
|
|
1772
|
+
projectId: project_id,
|
|
1773
|
+
sceneIds: scene_ids,
|
|
1774
|
+
part,
|
|
1775
|
+
chapter,
|
|
1776
|
+
onlyStale: false,
|
|
1777
|
+
});
|
|
1778
|
+
if (!targetResolution.ok) {
|
|
1779
|
+
return errorResponse(targetResolution.code, targetResolution.message, targetResolution.details);
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
const targetScenes = targetResolution.rows;
|
|
1783
|
+
if (targetScenes.length > max_scenes) {
|
|
1784
|
+
return errorResponse(
|
|
1785
|
+
"VALIDATION_ERROR",
|
|
1786
|
+
`Matched ${targetScenes.length} scenes, which exceeds max_scenes=${max_scenes}.`,
|
|
1787
|
+
{ matched_scenes: targetScenes.length, max_scenes, project_id }
|
|
1788
|
+
);
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
const sceneAnalyses = [];
|
|
1792
|
+
for (const scene of targetScenes) {
|
|
1793
|
+
let prose;
|
|
1794
|
+
try {
|
|
1795
|
+
const raw = fs.readFileSync(scene.file_path, "utf8");
|
|
1796
|
+
prose = matter(raw).content;
|
|
1797
|
+
} catch {
|
|
1798
|
+
sceneAnalyses.push({
|
|
1799
|
+
scene_id: scene.scene_id,
|
|
1800
|
+
observed: {},
|
|
1801
|
+
drift: [{ field: "scene_file", declared: "readable", observed: "unreadable" }],
|
|
1802
|
+
});
|
|
1803
|
+
continue;
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
const analysis = analyzeSceneStyleguideDrift({
|
|
1807
|
+
prose,
|
|
1808
|
+
resolvedConfig: resolved.resolved_config,
|
|
1809
|
+
});
|
|
1810
|
+
sceneAnalyses.push({
|
|
1811
|
+
scene_id: scene.scene_id,
|
|
1812
|
+
observed: analysis.observed,
|
|
1813
|
+
drift: analysis.drift,
|
|
1814
|
+
});
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
const suggestedUpdates = suggestStyleguideUpdatesFromScenes({
|
|
1818
|
+
sceneAnalyses,
|
|
1819
|
+
resolvedConfig: resolved.resolved_config,
|
|
1820
|
+
minAgreement: min_agreement,
|
|
1821
|
+
});
|
|
1822
|
+
|
|
1823
|
+
const filteredScenes = include_clean_scenes
|
|
1824
|
+
? sceneAnalyses
|
|
1825
|
+
: sceneAnalyses.filter((scene) => scene.drift.length > 0);
|
|
1826
|
+
|
|
1827
|
+
const driftByField = {};
|
|
1828
|
+
for (const scene of sceneAnalyses) {
|
|
1829
|
+
for (const entry of scene.drift) {
|
|
1830
|
+
driftByField[entry.field] = (driftByField[entry.field] ?? 0) + 1;
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
return jsonResponse({
|
|
1835
|
+
ok: true,
|
|
1836
|
+
project_id,
|
|
1837
|
+
checked_scenes: sceneAnalyses.length,
|
|
1838
|
+
scenes_with_drift: sceneAnalyses.filter((scene) => scene.drift.length > 0).length,
|
|
1839
|
+
drift_by_field: driftByField,
|
|
1840
|
+
scene_results: filteredScenes,
|
|
1841
|
+
suggested_updates: suggestedUpdates,
|
|
1842
|
+
});
|
|
1843
|
+
}
|
|
1844
|
+
);
|
|
1845
|
+
|
|
1846
|
+
s.tool(
|
|
1847
|
+
"setup_prose_styleguide_skill",
|
|
1848
|
+
"Generate skills/prose-styleguide.md from the resolved prose styleguide config and universal craft rules.",
|
|
1849
|
+
{
|
|
1850
|
+
project_id: z.string().optional().describe("Optional project ID for scoped config resolution (e.g. 'the-lamb' or 'universe-1/book-1')."),
|
|
1851
|
+
overwrite: z.boolean().optional().describe("If true, replaces an existing skills/prose-styleguide.md file."),
|
|
1852
|
+
},
|
|
1853
|
+
async ({ project_id, overwrite = false }) => {
|
|
1854
|
+
if (project_id !== undefined) {
|
|
1855
|
+
const projectIdCheck = validateProjectId(project_id);
|
|
1856
|
+
if (!projectIdCheck.ok) {
|
|
1857
|
+
return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
if (!SYNC_DIR_WRITABLE) {
|
|
1862
|
+
return errorResponse(
|
|
1863
|
+
"SYNC_DIR_NOT_WRITABLE",
|
|
1864
|
+
"Cannot write prose styleguide skill because WRITING_SYNC_DIR is not writable in this runtime.",
|
|
1865
|
+
{ sync_dir: SYNC_DIR_ABS }
|
|
1866
|
+
);
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
const resolved = resolveStyleguideConfig({
|
|
1870
|
+
syncDir: SYNC_DIR,
|
|
1871
|
+
projectId: project_id,
|
|
1872
|
+
});
|
|
1873
|
+
if (!resolved.ok) {
|
|
1874
|
+
return errorResponse(
|
|
1875
|
+
resolved.error.code,
|
|
1876
|
+
resolved.error.message,
|
|
1877
|
+
resolved.error.details
|
|
1878
|
+
);
|
|
1879
|
+
}
|
|
1880
|
+
if (resolved.setup_required || !resolved.resolved_config) {
|
|
1881
|
+
return errorResponse(
|
|
1882
|
+
"STYLEGUIDE_CONFIG_REQUIRED",
|
|
1883
|
+
"Cannot generate prose-styleguide.md before prose-styleguide.config.yaml is set up.",
|
|
1884
|
+
{
|
|
1885
|
+
project_id: project_id ?? null,
|
|
1886
|
+
next_step: "Run setup_prose_styleguide_config first.",
|
|
1887
|
+
}
|
|
1888
|
+
);
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
const skillPath = path.join(SYNC_DIR, PROSE_STYLEGUIDE_SKILL_DIRNAME, PROSE_STYLEGUIDE_SKILL_BASENAME);
|
|
1892
|
+
if (!isPathCandidateInsideSyncDir(skillPath)) {
|
|
1893
|
+
return errorResponse(
|
|
1894
|
+
"INVALID_SKILL_PATH",
|
|
1895
|
+
"Resolved prose styleguide skill path must be inside WRITING_SYNC_DIR.",
|
|
1896
|
+
{ target_path: path.resolve(skillPath), sync_dir: SYNC_DIR_ABS }
|
|
1897
|
+
);
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
if (fs.existsSync(skillPath) && !overwrite) {
|
|
1901
|
+
return errorResponse(
|
|
1902
|
+
"STYLEGUIDE_SKILL_EXISTS",
|
|
1903
|
+
"skills/prose-styleguide.md already exists. Set overwrite=true to replace it.",
|
|
1904
|
+
{ target_path: path.resolve(skillPath) }
|
|
1905
|
+
);
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
const generated = buildProseStyleguideSkill({
|
|
1909
|
+
resolvedConfig: resolved.resolved_config,
|
|
1910
|
+
sources: resolved.sources,
|
|
1911
|
+
projectId: project_id ?? null,
|
|
1912
|
+
});
|
|
1913
|
+
if (!generated.ok) {
|
|
1914
|
+
return errorResponse(generated.error.code, generated.error.message);
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
fs.mkdirSync(path.dirname(skillPath), { recursive: true });
|
|
1918
|
+
fs.writeFileSync(skillPath, generated.markdown, "utf8");
|
|
1919
|
+
|
|
1920
|
+
return jsonResponse({
|
|
1921
|
+
ok: true,
|
|
1922
|
+
file_path: path.resolve(skillPath),
|
|
1923
|
+
project_id: project_id ?? null,
|
|
1924
|
+
injected_rules: generated.injected_rules,
|
|
1925
|
+
source_count: resolved.sources.length,
|
|
1926
|
+
});
|
|
1927
|
+
}
|
|
1928
|
+
);
|
|
1929
|
+
|
|
1516
1930
|
// ---- preview_review_bundle ----------------------------------------------
|
|
1517
1931
|
s.tool(
|
|
1518
1932
|
"preview_review_bundle",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hanna84/mcp-writing",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.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",
|
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
"scene-character-normalization.js",
|
|
19
19
|
"review-bundles.js",
|
|
20
20
|
"prose-styleguide.js",
|
|
21
|
+
"prose-styleguide-drift.js",
|
|
22
|
+
"prose-styleguide-skill.js",
|
|
21
23
|
"scripts/",
|
|
22
24
|
"README.md",
|
|
23
25
|
"CHANGELOG.md"
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
function detectQuotationStyle(prose) {
|
|
2
|
+
const counts = {
|
|
3
|
+
double: (prose.match(/"[^"]{2,}"/g) ?? []).length,
|
|
4
|
+
guillemets: (prose.match(/«[^»]{2,}»/g) ?? []).length,
|
|
5
|
+
low9: (prose.match(/„[^“]{2,}“/g) ?? []).length,
|
|
6
|
+
corner_brackets: (prose.match(/「[^」]{2,}」/g) ?? []).length,
|
|
7
|
+
dialogue_dash_en: (prose.match(/^\s*–\s/mg) ?? []).length,
|
|
8
|
+
dialogue_dash_em: (prose.match(/^\s*—\s/mg) ?? []).length,
|
|
9
|
+
single: (prose.match(/'[^'\n]{2,}'/g) ?? []).length,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
let best = null;
|
|
13
|
+
let bestCount = 0;
|
|
14
|
+
for (const [style, count] of Object.entries(counts)) {
|
|
15
|
+
if (count > bestCount) {
|
|
16
|
+
best = style;
|
|
17
|
+
bestCount = count;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return bestCount > 0 ? best : null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function detectEmDashSpacing(prose) {
|
|
24
|
+
const spaced = (prose.match(/\s—\s/g) ?? []).length;
|
|
25
|
+
const closed = (prose.match(/\S—\S/g) ?? []).length;
|
|
26
|
+
if (spaced === 0 && closed === 0) return null;
|
|
27
|
+
return spaced >= closed ? "spaced" : "closed";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function detectSpellingVariant(prose) {
|
|
31
|
+
const lower = prose.toLowerCase();
|
|
32
|
+
const ukSignals = ["colour", "realise", "centre", "honour", "travelling"];
|
|
33
|
+
const usSignals = ["color", "realize", "center", "honor", "traveling"];
|
|
34
|
+
|
|
35
|
+
const countHits = (signals) => signals.reduce((sum, word) => {
|
|
36
|
+
const re = new RegExp(`\\b${word}\\b`, "g");
|
|
37
|
+
return sum + (lower.match(re) ?? []).length;
|
|
38
|
+
}, 0);
|
|
39
|
+
|
|
40
|
+
const uk = countHits(ukSignals);
|
|
41
|
+
const us = countHits(usSignals);
|
|
42
|
+
if (uk === 0 && us === 0) return null;
|
|
43
|
+
return uk >= us ? "uk" : "us";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function detectTenseHint(prose) {
|
|
47
|
+
const lower = prose.toLowerCase();
|
|
48
|
+
const past = (lower.match(/\b(was|were|had|did)\b/g) ?? []).length;
|
|
49
|
+
const present = (lower.match(/\b(is|are|has|do)\b/g) ?? []).length;
|
|
50
|
+
if (past === 0 && present === 0) return null;
|
|
51
|
+
return present >= past ? "present" : "past";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function mostCommonValue(values) {
|
|
55
|
+
const counts = new Map();
|
|
56
|
+
for (const value of values) {
|
|
57
|
+
if (!value) continue;
|
|
58
|
+
counts.set(value, (counts.get(value) ?? 0) + 1);
|
|
59
|
+
}
|
|
60
|
+
if (counts.size === 0) return null;
|
|
61
|
+
|
|
62
|
+
let bestValue = null;
|
|
63
|
+
let bestCount = 0;
|
|
64
|
+
for (const [value, count] of counts.entries()) {
|
|
65
|
+
if (count > bestCount) {
|
|
66
|
+
bestValue = value;
|
|
67
|
+
bestCount = count;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return { value: bestValue, count: bestCount, total: values.filter(Boolean).length };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function detectStyleguideSignals(prose) {
|
|
74
|
+
return {
|
|
75
|
+
quotation_style: detectQuotationStyle(prose),
|
|
76
|
+
em_dash_spacing: detectEmDashSpacing(prose),
|
|
77
|
+
spelling: detectSpellingVariant(prose),
|
|
78
|
+
tense: detectTenseHint(prose),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function analyzeSceneStyleguideDrift({ prose, resolvedConfig }) {
|
|
83
|
+
const observed = detectStyleguideSignals(prose);
|
|
84
|
+
const drift = [];
|
|
85
|
+
|
|
86
|
+
for (const field of ["quotation_style", "em_dash_spacing", "spelling", "tense"]) {
|
|
87
|
+
const declared = resolvedConfig?.[field];
|
|
88
|
+
const seen = observed[field];
|
|
89
|
+
if (!declared || !seen) continue;
|
|
90
|
+
if (declared !== seen) {
|
|
91
|
+
drift.push({ field, declared, observed: seen });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return { observed, drift };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function suggestStyleguideUpdatesFromScenes({
|
|
99
|
+
sceneAnalyses,
|
|
100
|
+
resolvedConfig,
|
|
101
|
+
minAgreement = 0.6,
|
|
102
|
+
minEvidence = 3,
|
|
103
|
+
}) {
|
|
104
|
+
const suggestions = {};
|
|
105
|
+
|
|
106
|
+
for (const field of ["quotation_style", "em_dash_spacing", "spelling", "tense"]) {
|
|
107
|
+
const values = sceneAnalyses.map((scene) => scene.observed?.[field] ?? null);
|
|
108
|
+
const common = mostCommonValue(values);
|
|
109
|
+
if (!common) continue;
|
|
110
|
+
if (common.total < minEvidence) continue;
|
|
111
|
+
|
|
112
|
+
const agreement = common.total > 0 ? common.count / common.total : 0;
|
|
113
|
+
const fieldThreshold = field === "tense" ? Math.max(minAgreement, 0.75) : minAgreement;
|
|
114
|
+
if (agreement < fieldThreshold) continue;
|
|
115
|
+
if (resolvedConfig?.[field] === common.value) continue;
|
|
116
|
+
|
|
117
|
+
suggestions[field] = {
|
|
118
|
+
suggested_value: common.value,
|
|
119
|
+
agreement,
|
|
120
|
+
based_on_scenes: common.total,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return suggestions;
|
|
125
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
export const PROSE_STYLEGUIDE_SKILL_DIRNAME = "skills";
|
|
2
|
+
export const PROSE_STYLEGUIDE_SKILL_BASENAME = "prose-styleguide.md";
|
|
3
|
+
|
|
4
|
+
const LANGUAGE_LABELS = {
|
|
5
|
+
english_us: "English (US)",
|
|
6
|
+
english_uk: "English (UK)",
|
|
7
|
+
english_au: "English (AU)",
|
|
8
|
+
english_ca: "English (CA)",
|
|
9
|
+
swedish: "Swedish",
|
|
10
|
+
norwegian: "Norwegian",
|
|
11
|
+
danish: "Danish",
|
|
12
|
+
finnish: "Finnish",
|
|
13
|
+
french: "French",
|
|
14
|
+
italian: "Italian",
|
|
15
|
+
russian: "Russian",
|
|
16
|
+
portuguese_pt: "Portuguese (PT)",
|
|
17
|
+
portuguese_br: "Portuguese (BR)",
|
|
18
|
+
german: "German",
|
|
19
|
+
dutch: "Dutch",
|
|
20
|
+
polish: "Polish",
|
|
21
|
+
czech: "Czech",
|
|
22
|
+
hungarian: "Hungarian",
|
|
23
|
+
spanish: "Spanish",
|
|
24
|
+
irish: "Irish",
|
|
25
|
+
japanese: "Japanese",
|
|
26
|
+
korean: "Korean",
|
|
27
|
+
chinese_traditional: "Chinese (Traditional)",
|
|
28
|
+
chinese_simplified: "Chinese (Simplified)",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const CONFIG_RULE_RENDERERS = {
|
|
32
|
+
language: (value) => `Primary writing language: ${LANGUAGE_LABELS[value] ?? value}.`,
|
|
33
|
+
spelling: (value) => `Spelling variant: ${value.toUpperCase()}.`,
|
|
34
|
+
quotation_style: (value) => {
|
|
35
|
+
const labels = {
|
|
36
|
+
double: "double quotes",
|
|
37
|
+
single: "single quotes",
|
|
38
|
+
guillemets: "guillemets (« »)",
|
|
39
|
+
low9: "low-9 quotation marks",
|
|
40
|
+
dialogue_dash_en: "Scandinavian en-dash dialogue",
|
|
41
|
+
dialogue_dash_em: "Spanish/Irish em-dash dialogue",
|
|
42
|
+
corner_brackets: "corner brackets (「 」)",
|
|
43
|
+
};
|
|
44
|
+
return `Dialogue quotation style: ${labels[value] ?? value}.`;
|
|
45
|
+
},
|
|
46
|
+
quotation_style_nested: (value) => `Nested quotation style: ${value}.`,
|
|
47
|
+
em_dash_spacing: (value) => `Em dash spacing: ${value}.`,
|
|
48
|
+
ellipsis_style: (value) => `Ellipsis style: ${value}.`,
|
|
49
|
+
abbreviation_periods: (value) => `Abbreviation periods: ${value}.`,
|
|
50
|
+
oxford_comma: (value) => `Oxford comma: ${value}.`,
|
|
51
|
+
numbers: (value) => `Number formatting rule: ${value}.`,
|
|
52
|
+
date_format: (value) => `Date format: ${value}.`,
|
|
53
|
+
time_format: (value) => `Time format: ${value}.`,
|
|
54
|
+
tense: (value) => `Default narrative tense: ${value}. Flag deviations as questions, not hard errors.`,
|
|
55
|
+
pov: (value) => `Default POV: ${value}. Flag shifts as intentional-or-drift questions.`,
|
|
56
|
+
dialogue_tags: (value) => `Dialogue tag policy: ${value}.`,
|
|
57
|
+
sentence_fragments: (value) => `Sentence fragments policy: ${value}.`,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export function buildProseStyleguideSkill({ resolvedConfig, sources = [], projectId = null }) {
|
|
61
|
+
if (!resolvedConfig || typeof resolvedConfig !== "object") {
|
|
62
|
+
return {
|
|
63
|
+
ok: false,
|
|
64
|
+
error: {
|
|
65
|
+
code: "INVALID_STYLEGUIDE_CONFIG",
|
|
66
|
+
message: "Cannot generate prose-styleguide.md without a resolved config object.",
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const injectedRules = [];
|
|
72
|
+
for (const [field, renderRule] of Object.entries(CONFIG_RULE_RENDERERS)) {
|
|
73
|
+
if (resolvedConfig[field] === undefined) continue;
|
|
74
|
+
injectedRules.push(renderRule(resolvedConfig[field]));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const sourceLines = sources.length
|
|
78
|
+
? sources.map((source) => `- ${source.scope}: ${source.file_path}`)
|
|
79
|
+
: ["- none"];
|
|
80
|
+
|
|
81
|
+
const voiceNotes = typeof resolvedConfig.voice_notes === "string" && resolvedConfig.voice_notes.trim()
|
|
82
|
+
? resolvedConfig.voice_notes.trim().split("\n").map((line) => `> ${line}`).join("\n")
|
|
83
|
+
: "> None provided.";
|
|
84
|
+
|
|
85
|
+
const markdown = [
|
|
86
|
+
"# Prose Styleguide",
|
|
87
|
+
"",
|
|
88
|
+
"## Standing Order",
|
|
89
|
+
"Apply this styleguide by default for prose critique and edits. Preserve author voice over mechanical cleanup.",
|
|
90
|
+
"",
|
|
91
|
+
"## Resolved Scope",
|
|
92
|
+
`- Project scope: ${projectId ?? "sync-root default"}`,
|
|
93
|
+
...sourceLines,
|
|
94
|
+
"",
|
|
95
|
+
"## Mechanical Conventions",
|
|
96
|
+
"These are injected from prose-styleguide.config.yaml and should be applied consistently:",
|
|
97
|
+
...injectedRules.map((rule) => `- ${rule}`),
|
|
98
|
+
"",
|
|
99
|
+
"## Universal Craft Rules",
|
|
100
|
+
"- Identify scene purpose before proposing changes.",
|
|
101
|
+
"- Require transformation (emotional, relational, narrative, or thematic).",
|
|
102
|
+
"- Prefer critique before rewrite.",
|
|
103
|
+
"- Preserve cadence and specificity; avoid flattening voice.",
|
|
104
|
+
"- Ask before normalizing intentional instability (flashbacks, POV drift, syntax breaks).",
|
|
105
|
+
"",
|
|
106
|
+
"## Review Posture",
|
|
107
|
+
"- Prioritize structural issues, then convention drift, then line-level polish.",
|
|
108
|
+
"- Treat convention drift as a question when intent may be deliberate.",
|
|
109
|
+
"",
|
|
110
|
+
"## Edit Posture",
|
|
111
|
+
"- Do not shorten unless requested.",
|
|
112
|
+
"- Apply conventions consistently while preserving tone.",
|
|
113
|
+
"- Justify significant rewrites.",
|
|
114
|
+
"",
|
|
115
|
+
"## Voice Notes",
|
|
116
|
+
voiceNotes,
|
|
117
|
+
"",
|
|
118
|
+
].join("\n");
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
ok: true,
|
|
122
|
+
markdown,
|
|
123
|
+
injected_rules: injectedRules,
|
|
124
|
+
};
|
|
125
|
+
}
|
package/prose-styleguide.js
CHANGED
|
@@ -67,6 +67,25 @@ export const STYLEGUIDE_ENUMS = Object.freeze(
|
|
|
67
67
|
)
|
|
68
68
|
);
|
|
69
69
|
|
|
70
|
+
const STYLEGUIDE_FIELD_ORDER = [
|
|
71
|
+
"language",
|
|
72
|
+
"spelling",
|
|
73
|
+
"quotation_style",
|
|
74
|
+
"quotation_style_nested",
|
|
75
|
+
"em_dash_spacing",
|
|
76
|
+
"ellipsis_style",
|
|
77
|
+
"abbreviation_periods",
|
|
78
|
+
"oxford_comma",
|
|
79
|
+
"numbers",
|
|
80
|
+
"date_format",
|
|
81
|
+
"time_format",
|
|
82
|
+
"tense",
|
|
83
|
+
"pov",
|
|
84
|
+
"dialogue_tags",
|
|
85
|
+
"sentence_fragments",
|
|
86
|
+
"voice_notes",
|
|
87
|
+
];
|
|
88
|
+
|
|
70
89
|
// Fields that are valid in a config but are not enum-constrained.
|
|
71
90
|
const SPECIAL_FIELDS = new Set(["voice_notes"]);
|
|
72
91
|
|
|
@@ -344,6 +363,98 @@ function readConfigFile(filePath) {
|
|
|
344
363
|
};
|
|
345
364
|
}
|
|
346
365
|
|
|
366
|
+
function configPathForScope(syncDir, scope, projectId) {
|
|
367
|
+
if (scope === "sync_root") {
|
|
368
|
+
return path.join(syncDir, STYLEGUIDE_CONFIG_BASENAME);
|
|
369
|
+
}
|
|
370
|
+
return path.join(projectRootFromId(syncDir, projectId), STYLEGUIDE_CONFIG_BASENAME);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function prepareStyleguideConfigUpdate({ syncDir, scope, projectId, updates = {} }) {
|
|
374
|
+
if (scope === "project_root" && !projectId) {
|
|
375
|
+
return {
|
|
376
|
+
ok: false,
|
|
377
|
+
error: {
|
|
378
|
+
code: "PROJECT_ID_REQUIRED",
|
|
379
|
+
message: "project_id is required when scope=project_root.",
|
|
380
|
+
details: { scope, project_id: projectId ?? null },
|
|
381
|
+
},
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const filePath = configPathForScope(syncDir, scope, projectId);
|
|
386
|
+
const current = readConfigFile(filePath);
|
|
387
|
+
|
|
388
|
+
if (current === null) {
|
|
389
|
+
return {
|
|
390
|
+
ok: false,
|
|
391
|
+
error: {
|
|
392
|
+
code: "STYLEGUIDE_CONFIG_NOT_FOUND",
|
|
393
|
+
message: "Cannot update styleguide config because no config exists at the requested scope.",
|
|
394
|
+
details: { file_path: filePath, scope, project_id: projectId ?? null },
|
|
395
|
+
},
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (!current.ok) {
|
|
400
|
+
return {
|
|
401
|
+
ok: false,
|
|
402
|
+
error: {
|
|
403
|
+
code: "INVALID_STYLEGUIDE_CONFIG",
|
|
404
|
+
message: "Styleguide config validation failed.",
|
|
405
|
+
details: { file_path: filePath, issues: current.errors },
|
|
406
|
+
},
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const validatedUpdates = validateConfig(updates, "<updates>");
|
|
411
|
+
if (validatedUpdates.errors.length > 0) {
|
|
412
|
+
return {
|
|
413
|
+
ok: false,
|
|
414
|
+
error: {
|
|
415
|
+
code: "INVALID_STYLEGUIDE_UPDATE",
|
|
416
|
+
message: "Requested styleguide updates failed validation.",
|
|
417
|
+
details: validatedUpdates.errors,
|
|
418
|
+
},
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const merged = Object.create(null);
|
|
423
|
+
Object.assign(merged, current.config, validatedUpdates.normalized);
|
|
424
|
+
|
|
425
|
+
const ordered = Object.create(null);
|
|
426
|
+
for (const key of STYLEGUIDE_FIELD_ORDER) {
|
|
427
|
+
if (merged[key] !== undefined) {
|
|
428
|
+
ordered[key] = merged[key];
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const changedFields = [];
|
|
433
|
+
const allKeys = new Set([...Object.keys(current.config ?? {}), ...Object.keys(ordered)]);
|
|
434
|
+
for (const key of allKeys) {
|
|
435
|
+
if (current.config?.[key] !== ordered[key]) {
|
|
436
|
+
changedFields.push({
|
|
437
|
+
field: key,
|
|
438
|
+
before: current.config?.[key],
|
|
439
|
+
after: ordered[key],
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
return {
|
|
445
|
+
ok: true,
|
|
446
|
+
file_path: filePath,
|
|
447
|
+
scope,
|
|
448
|
+
project_id: projectId ?? null,
|
|
449
|
+
current_config: current.config,
|
|
450
|
+
config: ordered,
|
|
451
|
+
changed_fields: changedFields,
|
|
452
|
+
warnings: {
|
|
453
|
+
unknown_fields: validatedUpdates.unknownFields,
|
|
454
|
+
},
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
347
458
|
function getConfigCandidates(syncDir, projectId) {
|
|
348
459
|
const candidates = [
|
|
349
460
|
{
|
|
@@ -442,6 +553,80 @@ export function buildStyleguideConfigDraft({ language, overrides = {}, voice_not
|
|
|
442
553
|
};
|
|
443
554
|
}
|
|
444
555
|
|
|
556
|
+
export function summarizeStyleguideConfig({ resolvedConfig, inferredDefaults = {} }) {
|
|
557
|
+
if (!resolvedConfig || typeof resolvedConfig !== "object") {
|
|
558
|
+
return {
|
|
559
|
+
ok: false,
|
|
560
|
+
error: {
|
|
561
|
+
code: "INVALID_STYLEGUIDE_CONFIG",
|
|
562
|
+
message: "Cannot summarize styleguide config without a resolved config object.",
|
|
563
|
+
},
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const lines = [];
|
|
568
|
+
if (resolvedConfig.language) lines.push(`Writing language: ${resolvedConfig.language}.`);
|
|
569
|
+
if (resolvedConfig.spelling) lines.push(`Spelling variant: ${resolvedConfig.spelling}.`);
|
|
570
|
+
if (resolvedConfig.quotation_style) lines.push(`Dialogue punctuation uses ${resolvedConfig.quotation_style}.`);
|
|
571
|
+
if (resolvedConfig.quotation_style_nested) lines.push(`Nested quotations use ${resolvedConfig.quotation_style_nested}.`);
|
|
572
|
+
if (resolvedConfig.tense) lines.push(`Default narrative tense: ${resolvedConfig.tense}.`);
|
|
573
|
+
if (resolvedConfig.pov) lines.push(`Default POV: ${resolvedConfig.pov}.`);
|
|
574
|
+
if (resolvedConfig.dialogue_tags) lines.push(`Dialogue tag policy: ${resolvedConfig.dialogue_tags}.`);
|
|
575
|
+
if (resolvedConfig.sentence_fragments) lines.push(`Sentence fragments: ${resolvedConfig.sentence_fragments}.`);
|
|
576
|
+
if (resolvedConfig.date_format) lines.push(`Date format: ${resolvedConfig.date_format}.`);
|
|
577
|
+
if (resolvedConfig.time_format) lines.push(`Time format: ${resolvedConfig.time_format}.`);
|
|
578
|
+
if (resolvedConfig.voice_notes) lines.push(`Voice notes: ${resolvedConfig.voice_notes}`);
|
|
579
|
+
|
|
580
|
+
const inferred = Object.keys(inferredDefaults);
|
|
581
|
+
if (inferred.length > 0) {
|
|
582
|
+
lines.push(`Inferred defaults currently fill: ${inferred.join(", ")}.`);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
return {
|
|
586
|
+
ok: true,
|
|
587
|
+
summary_text: lines.join(" "),
|
|
588
|
+
summary_lines: lines,
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
export function updateStyleguideConfig({ syncDir, scope, projectId, updates = {} }) {
|
|
593
|
+
const prepared = prepareStyleguideConfigUpdate({ syncDir, scope, projectId, updates });
|
|
594
|
+
if (!prepared.ok) return prepared;
|
|
595
|
+
|
|
596
|
+
if (prepared.changed_fields.length === 0) {
|
|
597
|
+
return {
|
|
598
|
+
ok: true,
|
|
599
|
+
file_path: prepared.file_path,
|
|
600
|
+
scope: prepared.scope,
|
|
601
|
+
project_id: prepared.project_id,
|
|
602
|
+
config: prepared.config,
|
|
603
|
+
changed_fields: prepared.changed_fields,
|
|
604
|
+
warnings: prepared.warnings,
|
|
605
|
+
noop: true,
|
|
606
|
+
message: "No changes detected for requested styleguide updates.",
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
fs.mkdirSync(path.dirname(prepared.file_path), { recursive: true });
|
|
611
|
+
fs.writeFileSync(prepared.file_path, yaml.dump(prepared.config, { lineWidth: 120 }), "utf8");
|
|
612
|
+
|
|
613
|
+
return {
|
|
614
|
+
ok: true,
|
|
615
|
+
file_path: prepared.file_path,
|
|
616
|
+
scope: prepared.scope,
|
|
617
|
+
project_id: prepared.project_id,
|
|
618
|
+
config: prepared.config,
|
|
619
|
+
changed_fields: prepared.changed_fields,
|
|
620
|
+
warnings: prepared.warnings,
|
|
621
|
+
noop: false,
|
|
622
|
+
message: "Styleguide config updated.",
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
export function previewStyleguideConfigUpdate({ syncDir, scope, projectId, updates = {} }) {
|
|
627
|
+
return prepareStyleguideConfigUpdate({ syncDir, scope, projectId, updates });
|
|
628
|
+
}
|
|
629
|
+
|
|
445
630
|
export function resolveStyleguideConfig({ syncDir, projectId }) {
|
|
446
631
|
const candidates = getConfigCandidates(syncDir, projectId);
|
|
447
632
|
const sources = [];
|