@hanna84/mcp-writing 2.1.0 → 2.3.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 +256 -0
- package/package.json +3 -1
- package/prose-styleguide-skill.js +125 -0
- package/prose-styleguide.js +494 -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.3.0](https://github.com/hannasdev/mcp-writing.git
|
|
8
|
+
/compare/v2.2.0...v2.3.0)
|
|
9
|
+
|
|
10
|
+
- feat(styleguide): generate prose styleguide skill from resolved config [`#85`](https://github.com/hannasdev/mcp-writing.git
|
|
11
|
+
/pull/85)
|
|
12
|
+
|
|
13
|
+
#### [v2.2.0](https://github.com/hannasdev/mcp-writing.git
|
|
14
|
+
/compare/v2.1.0...v2.2.0)
|
|
15
|
+
|
|
16
|
+
> 25 April 2026
|
|
17
|
+
|
|
18
|
+
- feat(styleguide): add prose styleguide config resolution and MCP tools [`#84`](https://github.com/hannasdev/mcp-writing.git
|
|
19
|
+
/pull/84)
|
|
20
|
+
- Release 2.2.0 [`58ecb48`](https://github.com/hannasdev/mcp-writing.git
|
|
21
|
+
/commit/58ecb480200498459e3d8876c06554d8a26c2fb5)
|
|
22
|
+
|
|
7
23
|
#### [v2.1.0](https://github.com/hannasdev/mcp-writing.git
|
|
8
24
|
/compare/v2.0.4...v2.1.0)
|
|
9
25
|
|
|
26
|
+
> 25 April 2026
|
|
27
|
+
|
|
10
28
|
- feat(tools): add scene character normalization utility [`#83`](https://github.com/hannasdev/mcp-writing.git
|
|
11
29
|
/pull/83)
|
|
30
|
+
- Release 2.1.0 [`33b5b31`](https://github.com/hannasdev/mcp-writing.git
|
|
31
|
+
/commit/33b5b31846576ef18385e6678f74cc109995fcac)
|
|
12
32
|
|
|
13
33
|
#### [v2.0.4](https://github.com/hannasdev/mcp-writing.git
|
|
14
34
|
/compare/v2.0.3...v2.0.4)
|
package/index.js
CHANGED
|
@@ -17,6 +17,17 @@ import { isGitAvailable, isGitRepository, initGitRepository, createSnapshot, lis
|
|
|
17
17
|
import { renderCharacterArcTemplate, renderCharacterSheetTemplate, renderPlaceSheetTemplate, slugifyEntityName } from "./world-entity-templates.js";
|
|
18
18
|
import { importScrivenerSync, validateProjectId } from "./importer.js";
|
|
19
19
|
import { ASYNC_PROGRESS_PREFIX } from "./async-progress.js";
|
|
20
|
+
import {
|
|
21
|
+
STYLEGUIDE_CONFIG_BASENAME,
|
|
22
|
+
STYLEGUIDE_ENUMS,
|
|
23
|
+
buildStyleguideConfigDraft,
|
|
24
|
+
resolveStyleguideConfig,
|
|
25
|
+
} from "./prose-styleguide.js";
|
|
26
|
+
import {
|
|
27
|
+
PROSE_STYLEGUIDE_SKILL_BASENAME,
|
|
28
|
+
PROSE_STYLEGUIDE_SKILL_DIRNAME,
|
|
29
|
+
buildProseStyleguideSkill,
|
|
30
|
+
} from "./prose-styleguide-skill.js";
|
|
20
31
|
import {
|
|
21
32
|
REVIEW_BUNDLE_PROFILES,
|
|
22
33
|
REVIEW_BUNDLE_STRICTNESS,
|
|
@@ -51,6 +62,31 @@ function isPathInsideSyncDir(candidatePath) {
|
|
|
51
62
|
return !(rel.startsWith("..") || path.isAbsolute(rel));
|
|
52
63
|
}
|
|
53
64
|
|
|
65
|
+
// Like isPathInsideSyncDir, but works for paths that do not yet exist by
|
|
66
|
+
// walking up to the nearest existing ancestor before canonicalising.
|
|
67
|
+
function isPathCandidateInsideSyncDir(candidatePath) {
|
|
68
|
+
const resolvedCandidate = path.resolve(candidatePath);
|
|
69
|
+
|
|
70
|
+
let existingAncestor = resolvedCandidate;
|
|
71
|
+
while (!fs.existsSync(existingAncestor)) {
|
|
72
|
+
const parent = path.dirname(existingAncestor);
|
|
73
|
+
if (parent === existingAncestor) break;
|
|
74
|
+
existingAncestor = parent;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const canonicalBase = (() => {
|
|
78
|
+
try {
|
|
79
|
+
return fs.realpathSync(existingAncestor);
|
|
80
|
+
} catch {
|
|
81
|
+
return existingAncestor;
|
|
82
|
+
}
|
|
83
|
+
})();
|
|
84
|
+
|
|
85
|
+
const canonical = path.resolve(canonicalBase, path.relative(existingAncestor, resolvedCandidate));
|
|
86
|
+
const rel = path.relative(SYNC_DIR_REAL, canonical);
|
|
87
|
+
return !(rel.startsWith("..") || path.isAbsolute(rel));
|
|
88
|
+
}
|
|
89
|
+
|
|
54
90
|
function resolveOutputDirWithinSync(outputDir) {
|
|
55
91
|
let resolvedOutputDir = path.resolve(outputDir);
|
|
56
92
|
let existingAncestor = resolvedOutputDir;
|
|
@@ -1346,6 +1382,226 @@ function createMcpServer() {
|
|
|
1346
1382
|
}
|
|
1347
1383
|
);
|
|
1348
1384
|
|
|
1385
|
+
// ---- prose styleguide ---------------------------------------------------
|
|
1386
|
+
s.tool(
|
|
1387
|
+
"setup_prose_styleguide_config",
|
|
1388
|
+
"Create prose-styleguide.config.yaml at sync root or project root using language defaults plus optional explicit overrides.",
|
|
1389
|
+
{
|
|
1390
|
+
scope: z.enum(["sync_root", "project_root"]).optional().describe("Config write target scope. Defaults to project_root when project_id is supplied, otherwise sync_root."),
|
|
1391
|
+
project_id: z.string().optional().describe("Project ID when writing project_root config (e.g. 'the-lamb' or 'universe-1/book-1')."),
|
|
1392
|
+
language: z.enum(STYLEGUIDE_ENUMS.language).describe("Primary writing language. Seeds language-specific defaults."),
|
|
1393
|
+
overrides: z.object({
|
|
1394
|
+
spelling: z.enum(STYLEGUIDE_ENUMS.spelling).optional(),
|
|
1395
|
+
quotation_style: z.enum(STYLEGUIDE_ENUMS.quotation_style).optional(),
|
|
1396
|
+
quotation_style_nested: z.enum(STYLEGUIDE_ENUMS.quotation_style_nested).optional(),
|
|
1397
|
+
em_dash_spacing: z.enum(STYLEGUIDE_ENUMS.em_dash_spacing).optional(),
|
|
1398
|
+
ellipsis_style: z.enum(STYLEGUIDE_ENUMS.ellipsis_style).optional(),
|
|
1399
|
+
abbreviation_periods: z.enum(STYLEGUIDE_ENUMS.abbreviation_periods).optional(),
|
|
1400
|
+
oxford_comma: z.enum(STYLEGUIDE_ENUMS.oxford_comma).optional(),
|
|
1401
|
+
numbers: z.enum(STYLEGUIDE_ENUMS.numbers).optional(),
|
|
1402
|
+
date_format: z.enum(STYLEGUIDE_ENUMS.date_format).optional(),
|
|
1403
|
+
time_format: z.enum(STYLEGUIDE_ENUMS.time_format).optional(),
|
|
1404
|
+
tense: z.string().optional(),
|
|
1405
|
+
pov: z.enum(STYLEGUIDE_ENUMS.pov).optional(),
|
|
1406
|
+
dialogue_tags: z.enum(STYLEGUIDE_ENUMS.dialogue_tags).optional(),
|
|
1407
|
+
sentence_fragments: z.enum(STYLEGUIDE_ENUMS.sentence_fragments).optional(),
|
|
1408
|
+
}).optional().describe("Optional overrides layered on top of language defaults."),
|
|
1409
|
+
voice_notes: z.string().optional().describe("Optional freeform voice notes to include in config."),
|
|
1410
|
+
overwrite: z.boolean().optional().describe("If true, replaces an existing config file at the target location."),
|
|
1411
|
+
},
|
|
1412
|
+
async ({ scope, project_id, language, overrides = {}, voice_notes, overwrite = false }) => {
|
|
1413
|
+
const resolvedScope = scope ?? (project_id ? "project_root" : "sync_root");
|
|
1414
|
+
|
|
1415
|
+
if (project_id !== undefined) {
|
|
1416
|
+
const projectIdCheck = validateProjectId(project_id);
|
|
1417
|
+
if (!projectIdCheck.ok) {
|
|
1418
|
+
return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
if (resolvedScope === "project_root" && !project_id) {
|
|
1423
|
+
return errorResponse(
|
|
1424
|
+
"PROJECT_ID_REQUIRED",
|
|
1425
|
+
"project_id is required when scope=project_root."
|
|
1426
|
+
);
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
if (!SYNC_DIR_WRITABLE) {
|
|
1430
|
+
return errorResponse(
|
|
1431
|
+
"SYNC_DIR_NOT_WRITABLE",
|
|
1432
|
+
"Cannot write styleguide config because WRITING_SYNC_DIR is not writable in this runtime.",
|
|
1433
|
+
{ sync_dir: SYNC_DIR_ABS }
|
|
1434
|
+
);
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
const targetPath = resolvedScope === "sync_root"
|
|
1438
|
+
? path.join(SYNC_DIR, STYLEGUIDE_CONFIG_BASENAME)
|
|
1439
|
+
: path.join(resolveProjectRoot(project_id), STYLEGUIDE_CONFIG_BASENAME);
|
|
1440
|
+
|
|
1441
|
+
if (!isPathCandidateInsideSyncDir(targetPath)) {
|
|
1442
|
+
return errorResponse(
|
|
1443
|
+
"INVALID_CONFIG_PATH",
|
|
1444
|
+
"Resolved styleguide config path must be inside WRITING_SYNC_DIR.",
|
|
1445
|
+
{ target_path: path.resolve(targetPath), sync_dir: SYNC_DIR_ABS }
|
|
1446
|
+
);
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
if (fs.existsSync(targetPath) && !overwrite) {
|
|
1450
|
+
return errorResponse(
|
|
1451
|
+
"STYLEGUIDE_CONFIG_EXISTS",
|
|
1452
|
+
"Styleguide config already exists at target path. Set overwrite=true to replace it.",
|
|
1453
|
+
{ target_path: path.resolve(targetPath) }
|
|
1454
|
+
);
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
const draft = buildStyleguideConfigDraft({
|
|
1458
|
+
language,
|
|
1459
|
+
overrides,
|
|
1460
|
+
voice_notes,
|
|
1461
|
+
});
|
|
1462
|
+
if (!draft.ok) {
|
|
1463
|
+
return errorResponse(
|
|
1464
|
+
draft.error.code,
|
|
1465
|
+
draft.error.message,
|
|
1466
|
+
draft.error.details
|
|
1467
|
+
);
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
1471
|
+
fs.writeFileSync(targetPath, yaml.dump(draft.config, { lineWidth: 120 }), "utf8");
|
|
1472
|
+
|
|
1473
|
+
return jsonResponse({
|
|
1474
|
+
ok: true,
|
|
1475
|
+
scope: resolvedScope,
|
|
1476
|
+
file_path: path.resolve(targetPath),
|
|
1477
|
+
config: draft.config,
|
|
1478
|
+
inferred_defaults: draft.inferred_defaults,
|
|
1479
|
+
warnings: draft.warnings,
|
|
1480
|
+
});
|
|
1481
|
+
}
|
|
1482
|
+
);
|
|
1483
|
+
|
|
1484
|
+
s.tool(
|
|
1485
|
+
"get_prose_styleguide_config",
|
|
1486
|
+
"Resolve prose-styleguide.config.yaml with cascading precedence (sync root, then universe root, then project root). Applies language-derived defaults and nested quotation defaults when omitted.",
|
|
1487
|
+
{
|
|
1488
|
+
project_id: z.string().optional().describe("Optional project ID for project-scoped resolution (e.g. 'the-lamb' or 'universe-1/book-1')."),
|
|
1489
|
+
},
|
|
1490
|
+
async ({ project_id }) => {
|
|
1491
|
+
if (project_id !== undefined) {
|
|
1492
|
+
const projectIdCheck = validateProjectId(project_id);
|
|
1493
|
+
if (!projectIdCheck.ok) {
|
|
1494
|
+
return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
const resolved = resolveStyleguideConfig({
|
|
1499
|
+
syncDir: SYNC_DIR,
|
|
1500
|
+
projectId: project_id,
|
|
1501
|
+
});
|
|
1502
|
+
|
|
1503
|
+
if (!resolved.ok) {
|
|
1504
|
+
return errorResponse(
|
|
1505
|
+
resolved.error.code,
|
|
1506
|
+
resolved.error.message,
|
|
1507
|
+
resolved.error.details
|
|
1508
|
+
);
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
return jsonResponse({
|
|
1512
|
+
ok: true,
|
|
1513
|
+
styleguide: resolved,
|
|
1514
|
+
next_step: resolved.setup_required
|
|
1515
|
+
? "No prose-styleguide.config.yaml was found. Run setup to create one at sync root or project root."
|
|
1516
|
+
: "Config resolved successfully.",
|
|
1517
|
+
});
|
|
1518
|
+
}
|
|
1519
|
+
);
|
|
1520
|
+
|
|
1521
|
+
s.tool(
|
|
1522
|
+
"setup_prose_styleguide_skill",
|
|
1523
|
+
"Generate skills/prose-styleguide.md from the resolved prose styleguide config and universal craft rules.",
|
|
1524
|
+
{
|
|
1525
|
+
project_id: z.string().optional().describe("Optional project ID for scoped config resolution (e.g. 'the-lamb' or 'universe-1/book-1')."),
|
|
1526
|
+
overwrite: z.boolean().optional().describe("If true, replaces an existing skills/prose-styleguide.md file."),
|
|
1527
|
+
},
|
|
1528
|
+
async ({ project_id, overwrite = false }) => {
|
|
1529
|
+
if (project_id !== undefined) {
|
|
1530
|
+
const projectIdCheck = validateProjectId(project_id);
|
|
1531
|
+
if (!projectIdCheck.ok) {
|
|
1532
|
+
return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
if (!SYNC_DIR_WRITABLE) {
|
|
1537
|
+
return errorResponse(
|
|
1538
|
+
"SYNC_DIR_NOT_WRITABLE",
|
|
1539
|
+
"Cannot write prose styleguide skill because WRITING_SYNC_DIR is not writable in this runtime.",
|
|
1540
|
+
{ sync_dir: SYNC_DIR_ABS }
|
|
1541
|
+
);
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
const resolved = resolveStyleguideConfig({
|
|
1545
|
+
syncDir: SYNC_DIR,
|
|
1546
|
+
projectId: project_id,
|
|
1547
|
+
});
|
|
1548
|
+
if (!resolved.ok) {
|
|
1549
|
+
return errorResponse(
|
|
1550
|
+
resolved.error.code,
|
|
1551
|
+
resolved.error.message,
|
|
1552
|
+
resolved.error.details
|
|
1553
|
+
);
|
|
1554
|
+
}
|
|
1555
|
+
if (resolved.setup_required || !resolved.resolved_config) {
|
|
1556
|
+
return errorResponse(
|
|
1557
|
+
"STYLEGUIDE_CONFIG_REQUIRED",
|
|
1558
|
+
"Cannot generate prose-styleguide.md before prose-styleguide.config.yaml is set up.",
|
|
1559
|
+
{
|
|
1560
|
+
project_id: project_id ?? null,
|
|
1561
|
+
next_step: "Run setup_prose_styleguide_config first.",
|
|
1562
|
+
}
|
|
1563
|
+
);
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
const skillPath = path.join(SYNC_DIR, PROSE_STYLEGUIDE_SKILL_DIRNAME, PROSE_STYLEGUIDE_SKILL_BASENAME);
|
|
1567
|
+
if (!isPathCandidateInsideSyncDir(skillPath)) {
|
|
1568
|
+
return errorResponse(
|
|
1569
|
+
"INVALID_SKILL_PATH",
|
|
1570
|
+
"Resolved prose styleguide skill path must be inside WRITING_SYNC_DIR.",
|
|
1571
|
+
{ target_path: path.resolve(skillPath), sync_dir: SYNC_DIR_ABS }
|
|
1572
|
+
);
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
if (fs.existsSync(skillPath) && !overwrite) {
|
|
1576
|
+
return errorResponse(
|
|
1577
|
+
"STYLEGUIDE_SKILL_EXISTS",
|
|
1578
|
+
"skills/prose-styleguide.md already exists. Set overwrite=true to replace it.",
|
|
1579
|
+
{ target_path: path.resolve(skillPath) }
|
|
1580
|
+
);
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
const generated = buildProseStyleguideSkill({
|
|
1584
|
+
resolvedConfig: resolved.resolved_config,
|
|
1585
|
+
sources: resolved.sources,
|
|
1586
|
+
projectId: project_id ?? null,
|
|
1587
|
+
});
|
|
1588
|
+
if (!generated.ok) {
|
|
1589
|
+
return errorResponse(generated.error.code, generated.error.message);
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
fs.mkdirSync(path.dirname(skillPath), { recursive: true });
|
|
1593
|
+
fs.writeFileSync(skillPath, generated.markdown, "utf8");
|
|
1594
|
+
|
|
1595
|
+
return jsonResponse({
|
|
1596
|
+
ok: true,
|
|
1597
|
+
file_path: path.resolve(skillPath),
|
|
1598
|
+
project_id: project_id ?? null,
|
|
1599
|
+
injected_rules: generated.injected_rules,
|
|
1600
|
+
source_count: resolved.sources.length,
|
|
1601
|
+
});
|
|
1602
|
+
}
|
|
1603
|
+
);
|
|
1604
|
+
|
|
1349
1605
|
// ---- preview_review_bundle ----------------------------------------------
|
|
1350
1606
|
s.tool(
|
|
1351
1607
|
"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.3.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",
|
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
"metadata-lint.js",
|
|
18
18
|
"scene-character-normalization.js",
|
|
19
19
|
"review-bundles.js",
|
|
20
|
+
"prose-styleguide.js",
|
|
21
|
+
"prose-styleguide-skill.js",
|
|
20
22
|
"scripts/",
|
|
21
23
|
"README.md",
|
|
22
24
|
"CHANGELOG.md"
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import yaml from "js-yaml";
|
|
4
|
+
|
|
5
|
+
export const STYLEGUIDE_CONFIG_BASENAME = "prose-styleguide.config.yaml";
|
|
6
|
+
|
|
7
|
+
const ENUMS = {
|
|
8
|
+
language: [
|
|
9
|
+
"english_us",
|
|
10
|
+
"english_uk",
|
|
11
|
+
"english_au",
|
|
12
|
+
"english_ca",
|
|
13
|
+
"swedish",
|
|
14
|
+
"norwegian",
|
|
15
|
+
"danish",
|
|
16
|
+
"finnish",
|
|
17
|
+
"french",
|
|
18
|
+
"italian",
|
|
19
|
+
"russian",
|
|
20
|
+
"portuguese_pt",
|
|
21
|
+
"portuguese_br",
|
|
22
|
+
"german",
|
|
23
|
+
"dutch",
|
|
24
|
+
"polish",
|
|
25
|
+
"czech",
|
|
26
|
+
"hungarian",
|
|
27
|
+
"spanish",
|
|
28
|
+
"irish",
|
|
29
|
+
"japanese",
|
|
30
|
+
"korean",
|
|
31
|
+
"chinese_traditional",
|
|
32
|
+
"chinese_simplified",
|
|
33
|
+
],
|
|
34
|
+
spelling: ["uk", "us", "au", "ca"],
|
|
35
|
+
quotation_style: [
|
|
36
|
+
"double",
|
|
37
|
+
"single",
|
|
38
|
+
"guillemets",
|
|
39
|
+
"low9",
|
|
40
|
+
"dialogue_dash_en",
|
|
41
|
+
"dialogue_dash_em",
|
|
42
|
+
"corner_brackets",
|
|
43
|
+
],
|
|
44
|
+
quotation_style_nested: [
|
|
45
|
+
"double",
|
|
46
|
+
"single",
|
|
47
|
+
"guillemets_single",
|
|
48
|
+
"low9_single",
|
|
49
|
+
"corner_brackets_double",
|
|
50
|
+
],
|
|
51
|
+
em_dash_spacing: ["closed", "spaced"],
|
|
52
|
+
ellipsis_style: ["three_periods", "ellipsis_char", "spaced"],
|
|
53
|
+
abbreviation_periods: ["with", "without"],
|
|
54
|
+
oxford_comma: ["yes", "no"],
|
|
55
|
+
numbers: ["spell_under_10", "spell_under_100", "always_spell", "numerals"],
|
|
56
|
+
date_format: ["mdy", "dmy"],
|
|
57
|
+
time_format: ["12h", "24h"],
|
|
58
|
+
tense: ["present", "past"],
|
|
59
|
+
pov: ["first", "third_limited", "third_omniscient"],
|
|
60
|
+
dialogue_tags: ["minimal", "expressive"],
|
|
61
|
+
sentence_fragments: ["disallow", "intentional"],
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export const STYLEGUIDE_ENUMS = Object.freeze(
|
|
65
|
+
Object.fromEntries(
|
|
66
|
+
Object.entries(ENUMS).map(([key, values]) => [key, [...values]])
|
|
67
|
+
)
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
// Fields that are valid in a config but are not enum-constrained.
|
|
71
|
+
const SPECIAL_FIELDS = new Set(["voice_notes"]);
|
|
72
|
+
|
|
73
|
+
const LANGUAGE_DEFAULTS = {
|
|
74
|
+
english_us: {
|
|
75
|
+
spelling: "us",
|
|
76
|
+
quotation_style: "double",
|
|
77
|
+
em_dash_spacing: "closed",
|
|
78
|
+
abbreviation_periods: "with",
|
|
79
|
+
oxford_comma: "yes",
|
|
80
|
+
date_format: "mdy",
|
|
81
|
+
},
|
|
82
|
+
english_uk: {
|
|
83
|
+
spelling: "uk",
|
|
84
|
+
quotation_style: "single",
|
|
85
|
+
em_dash_spacing: "spaced",
|
|
86
|
+
abbreviation_periods: "without",
|
|
87
|
+
oxford_comma: "no",
|
|
88
|
+
date_format: "dmy",
|
|
89
|
+
},
|
|
90
|
+
english_au: {
|
|
91
|
+
spelling: "au",
|
|
92
|
+
quotation_style: "double",
|
|
93
|
+
em_dash_spacing: "closed",
|
|
94
|
+
abbreviation_periods: "without",
|
|
95
|
+
oxford_comma: "yes",
|
|
96
|
+
date_format: "dmy",
|
|
97
|
+
},
|
|
98
|
+
english_ca: {
|
|
99
|
+
spelling: "ca",
|
|
100
|
+
quotation_style: "double",
|
|
101
|
+
em_dash_spacing: "spaced",
|
|
102
|
+
abbreviation_periods: "without",
|
|
103
|
+
oxford_comma: "yes",
|
|
104
|
+
date_format: "dmy",
|
|
105
|
+
},
|
|
106
|
+
swedish: {
|
|
107
|
+
quotation_style: "dialogue_dash_en",
|
|
108
|
+
em_dash_spacing: "spaced",
|
|
109
|
+
date_format: "dmy",
|
|
110
|
+
},
|
|
111
|
+
norwegian: {
|
|
112
|
+
quotation_style: "dialogue_dash_en",
|
|
113
|
+
em_dash_spacing: "spaced",
|
|
114
|
+
date_format: "dmy",
|
|
115
|
+
},
|
|
116
|
+
danish: {
|
|
117
|
+
quotation_style: "dialogue_dash_en",
|
|
118
|
+
em_dash_spacing: "spaced",
|
|
119
|
+
date_format: "dmy",
|
|
120
|
+
},
|
|
121
|
+
finnish: {
|
|
122
|
+
quotation_style: "guillemets",
|
|
123
|
+
em_dash_spacing: "spaced",
|
|
124
|
+
date_format: "dmy",
|
|
125
|
+
},
|
|
126
|
+
french: {
|
|
127
|
+
quotation_style: "guillemets",
|
|
128
|
+
em_dash_spacing: "spaced",
|
|
129
|
+
date_format: "dmy",
|
|
130
|
+
},
|
|
131
|
+
italian: {
|
|
132
|
+
quotation_style: "guillemets",
|
|
133
|
+
em_dash_spacing: "spaced",
|
|
134
|
+
date_format: "dmy",
|
|
135
|
+
},
|
|
136
|
+
russian: {
|
|
137
|
+
quotation_style: "guillemets",
|
|
138
|
+
em_dash_spacing: "spaced",
|
|
139
|
+
date_format: "dmy",
|
|
140
|
+
},
|
|
141
|
+
portuguese_pt: {
|
|
142
|
+
quotation_style: "guillemets",
|
|
143
|
+
em_dash_spacing: "spaced",
|
|
144
|
+
date_format: "dmy",
|
|
145
|
+
},
|
|
146
|
+
portuguese_br: {
|
|
147
|
+
quotation_style: "double",
|
|
148
|
+
em_dash_spacing: "closed",
|
|
149
|
+
date_format: "dmy",
|
|
150
|
+
},
|
|
151
|
+
german: {
|
|
152
|
+
quotation_style: "low9",
|
|
153
|
+
em_dash_spacing: "spaced",
|
|
154
|
+
date_format: "dmy",
|
|
155
|
+
},
|
|
156
|
+
dutch: {
|
|
157
|
+
quotation_style: "low9",
|
|
158
|
+
em_dash_spacing: "spaced",
|
|
159
|
+
date_format: "dmy",
|
|
160
|
+
},
|
|
161
|
+
polish: {
|
|
162
|
+
quotation_style: "low9",
|
|
163
|
+
em_dash_spacing: "spaced",
|
|
164
|
+
date_format: "dmy",
|
|
165
|
+
},
|
|
166
|
+
czech: {
|
|
167
|
+
quotation_style: "low9",
|
|
168
|
+
em_dash_spacing: "spaced",
|
|
169
|
+
date_format: "dmy",
|
|
170
|
+
},
|
|
171
|
+
hungarian: {
|
|
172
|
+
quotation_style: "low9",
|
|
173
|
+
em_dash_spacing: "spaced",
|
|
174
|
+
date_format: "dmy",
|
|
175
|
+
},
|
|
176
|
+
spanish: {
|
|
177
|
+
quotation_style: "dialogue_dash_em",
|
|
178
|
+
em_dash_spacing: "spaced",
|
|
179
|
+
date_format: "dmy",
|
|
180
|
+
},
|
|
181
|
+
irish: {
|
|
182
|
+
quotation_style: "dialogue_dash_em",
|
|
183
|
+
em_dash_spacing: "spaced",
|
|
184
|
+
date_format: "dmy",
|
|
185
|
+
},
|
|
186
|
+
japanese: {
|
|
187
|
+
quotation_style: "corner_brackets",
|
|
188
|
+
},
|
|
189
|
+
korean: {
|
|
190
|
+
quotation_style: "corner_brackets",
|
|
191
|
+
},
|
|
192
|
+
chinese_traditional: {
|
|
193
|
+
quotation_style: "corner_brackets",
|
|
194
|
+
},
|
|
195
|
+
chinese_simplified: {
|
|
196
|
+
quotation_style: "double",
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
function projectRootFromId(syncDir, projectId) {
|
|
201
|
+
if (!projectId.includes("/")) {
|
|
202
|
+
return path.join(syncDir, "projects", projectId);
|
|
203
|
+
}
|
|
204
|
+
const [universeId, projectSlug] = projectId.split("/");
|
|
205
|
+
return path.join(syncDir, "universes", universeId, projectSlug);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function inferNestedQuotationStyle(quotationStyle) {
|
|
209
|
+
if (quotationStyle === "double") return "single";
|
|
210
|
+
if (quotationStyle === "single") return "double";
|
|
211
|
+
if (quotationStyle === "guillemets") return "guillemets_single";
|
|
212
|
+
if (quotationStyle === "low9") return "low9_single";
|
|
213
|
+
if (quotationStyle === "corner_brackets") return "corner_brackets_double";
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function normalizeTense(value) {
|
|
218
|
+
if (typeof value !== "string") return null;
|
|
219
|
+
const trimmed = value.trim().toLowerCase();
|
|
220
|
+
if (!trimmed) return null;
|
|
221
|
+
|
|
222
|
+
if (trimmed.startsWith("present")) return "present";
|
|
223
|
+
if (trimmed.startsWith("past")) return "past";
|
|
224
|
+
return trimmed;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function normalizeConfigShape(rawConfig) {
|
|
228
|
+
const normalized = Object.create(null);
|
|
229
|
+
for (const [key, value] of Object.entries(rawConfig ?? {})) {
|
|
230
|
+
// Skip null/undefined — treat as unset, same as a missing key.
|
|
231
|
+
if (value === null || value === undefined) continue;
|
|
232
|
+
if (typeof value === "string") {
|
|
233
|
+
const trimmed = value.trim();
|
|
234
|
+
if (trimmed !== "") {
|
|
235
|
+
normalized[key] = trimmed;
|
|
236
|
+
}
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
normalized[key] = value;
|
|
240
|
+
}
|
|
241
|
+
return normalized;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function validateConfig(config, sourcePath) {
|
|
245
|
+
const normalized = normalizeConfigShape(config);
|
|
246
|
+
const sanitized = Object.create(null);
|
|
247
|
+
const errors = [];
|
|
248
|
+
const unknownFields = [];
|
|
249
|
+
|
|
250
|
+
for (const [key, value] of Object.entries(normalized)) {
|
|
251
|
+
if (!Object.hasOwn(ENUMS, key) && !SPECIAL_FIELDS.has(key)) {
|
|
252
|
+
unknownFields.push(key);
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (SPECIAL_FIELDS.has(key)) {
|
|
257
|
+
if (typeof value !== "string") {
|
|
258
|
+
errors.push({
|
|
259
|
+
code: "INVALID_TYPE",
|
|
260
|
+
field: key,
|
|
261
|
+
message: `${key} must be a string.`,
|
|
262
|
+
source: sourcePath,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
if (typeof value === "string") {
|
|
266
|
+
sanitized[key] = value;
|
|
267
|
+
}
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (typeof value !== "string") {
|
|
272
|
+
errors.push({
|
|
273
|
+
code: "INVALID_TYPE",
|
|
274
|
+
field: key,
|
|
275
|
+
message: `${key} must be a string enum value.`,
|
|
276
|
+
source: sourcePath,
|
|
277
|
+
});
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const valueToCheck = key === "tense" ? normalizeTense(value) : value;
|
|
282
|
+
if (!ENUMS[key].includes(valueToCheck)) {
|
|
283
|
+
errors.push({
|
|
284
|
+
code: "INVALID_ENUM",
|
|
285
|
+
field: key,
|
|
286
|
+
message: `${key} must be one of: ${ENUMS[key].join(", ")}.`,
|
|
287
|
+
source: sourcePath,
|
|
288
|
+
received: value,
|
|
289
|
+
});
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
sanitized[key] = value;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return {
|
|
297
|
+
normalized: sanitized,
|
|
298
|
+
errors,
|
|
299
|
+
unknownFields,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function readConfigFile(filePath) {
|
|
304
|
+
if (!fs.existsSync(filePath)) return null;
|
|
305
|
+
|
|
306
|
+
let parsed;
|
|
307
|
+
try {
|
|
308
|
+
parsed = yaml.load(fs.readFileSync(filePath, "utf8"));
|
|
309
|
+
} catch (error) {
|
|
310
|
+
return {
|
|
311
|
+
ok: false,
|
|
312
|
+
errors: [{
|
|
313
|
+
code: "INVALID_YAML",
|
|
314
|
+
message: error instanceof Error ? error.message : "Invalid YAML.",
|
|
315
|
+
source: filePath,
|
|
316
|
+
}],
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (parsed === null || parsed === undefined) {
|
|
321
|
+
return { ok: true, config: {} };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
325
|
+
return {
|
|
326
|
+
ok: false,
|
|
327
|
+
errors: [{
|
|
328
|
+
code: "INVALID_CONFIG",
|
|
329
|
+
message: "Config file must parse to an object.",
|
|
330
|
+
source: filePath,
|
|
331
|
+
}],
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const { normalized, errors, unknownFields } = validateConfig(parsed, filePath);
|
|
336
|
+
if (errors.length > 0) {
|
|
337
|
+
return { ok: false, errors };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return {
|
|
341
|
+
ok: true,
|
|
342
|
+
config: normalized,
|
|
343
|
+
unknown_fields: unknownFields,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function getConfigCandidates(syncDir, projectId) {
|
|
348
|
+
const candidates = [
|
|
349
|
+
{
|
|
350
|
+
scope: "sync_root",
|
|
351
|
+
file_path: path.join(syncDir, STYLEGUIDE_CONFIG_BASENAME),
|
|
352
|
+
},
|
|
353
|
+
];
|
|
354
|
+
|
|
355
|
+
if (!projectId) return candidates;
|
|
356
|
+
|
|
357
|
+
if (projectId.includes("/")) {
|
|
358
|
+
const [universeId] = projectId.split("/");
|
|
359
|
+
candidates.push({
|
|
360
|
+
scope: "universe_root",
|
|
361
|
+
file_path: path.join(syncDir, "universes", universeId, STYLEGUIDE_CONFIG_BASENAME),
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
candidates.push({
|
|
366
|
+
scope: "project_root",
|
|
367
|
+
file_path: path.join(projectRootFromId(syncDir, projectId), STYLEGUIDE_CONFIG_BASENAME),
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
return candidates;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function applyDerivedDefaults(config) {
|
|
374
|
+
const resolved = { ...config };
|
|
375
|
+
const inferred_defaults = {};
|
|
376
|
+
|
|
377
|
+
if (resolved.language && LANGUAGE_DEFAULTS[resolved.language]) {
|
|
378
|
+
const defaults = LANGUAGE_DEFAULTS[resolved.language];
|
|
379
|
+
for (const [key, value] of Object.entries(defaults)) {
|
|
380
|
+
if (resolved[key] === undefined) {
|
|
381
|
+
resolved[key] = value;
|
|
382
|
+
inferred_defaults[key] = value;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (!resolved.quotation_style_nested && resolved.quotation_style) {
|
|
388
|
+
const nested = inferNestedQuotationStyle(resolved.quotation_style);
|
|
389
|
+
if (nested) {
|
|
390
|
+
resolved.quotation_style_nested = nested;
|
|
391
|
+
inferred_defaults.quotation_style_nested = nested;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (resolved.tense) {
|
|
396
|
+
resolved.tense = normalizeTense(resolved.tense);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return { resolved, inferred_defaults };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
export function buildStyleguideConfigDraft({ language, overrides = {}, voice_notes }) {
|
|
403
|
+
const overrideValidation = validateConfig(overrides, "<overrides>");
|
|
404
|
+
if (overrideValidation.errors.length > 0) {
|
|
405
|
+
return {
|
|
406
|
+
ok: false,
|
|
407
|
+
error: {
|
|
408
|
+
code: "INVALID_STYLEGUIDE_OVERRIDE",
|
|
409
|
+
message: "Requested styleguide overrides failed validation.",
|
|
410
|
+
details: overrideValidation.errors,
|
|
411
|
+
},
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (!ENUMS.language.includes(language)) {
|
|
416
|
+
return {
|
|
417
|
+
ok: false,
|
|
418
|
+
error: {
|
|
419
|
+
code: "INVALID_STYLEGUIDE_LANGUAGE",
|
|
420
|
+
message: `language must be one of: ${ENUMS.language.join(", ")}.`,
|
|
421
|
+
},
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const merged = {
|
|
426
|
+
...overrideValidation.normalized,
|
|
427
|
+
language,
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
if (typeof voice_notes === "string" && voice_notes.trim()) {
|
|
431
|
+
merged.voice_notes = voice_notes.trim();
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const { resolved, inferred_defaults } = applyDerivedDefaults(merged);
|
|
435
|
+
return {
|
|
436
|
+
ok: true,
|
|
437
|
+
config: resolved,
|
|
438
|
+
inferred_defaults,
|
|
439
|
+
warnings: {
|
|
440
|
+
unknown_fields: overrideValidation.unknownFields,
|
|
441
|
+
},
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
export function resolveStyleguideConfig({ syncDir, projectId }) {
|
|
446
|
+
const candidates = getConfigCandidates(syncDir, projectId);
|
|
447
|
+
const sources = [];
|
|
448
|
+
const unknownFields = [];
|
|
449
|
+
const merged = Object.create(null);
|
|
450
|
+
|
|
451
|
+
for (const candidate of candidates) {
|
|
452
|
+
const loaded = readConfigFile(candidate.file_path);
|
|
453
|
+
if (loaded === null) continue;
|
|
454
|
+
|
|
455
|
+
if (!loaded.ok) {
|
|
456
|
+
return {
|
|
457
|
+
ok: false,
|
|
458
|
+
error: {
|
|
459
|
+
code: "INVALID_STYLEGUIDE_CONFIG",
|
|
460
|
+
message: "Styleguide config validation failed.",
|
|
461
|
+
details: {
|
|
462
|
+
file_path: candidate.file_path,
|
|
463
|
+
issues: loaded.errors,
|
|
464
|
+
},
|
|
465
|
+
},
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
Object.assign(merged, loaded.config);
|
|
470
|
+
if (loaded.unknown_fields?.length) {
|
|
471
|
+
for (const field of loaded.unknown_fields) {
|
|
472
|
+
unknownFields.push({ scope: candidate.scope, field, source: candidate.file_path });
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
sources.push({
|
|
477
|
+
scope: candidate.scope,
|
|
478
|
+
file_path: candidate.file_path,
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const { resolved, inferred_defaults } = applyDerivedDefaults(merged);
|
|
483
|
+
return {
|
|
484
|
+
ok: true,
|
|
485
|
+
config_found: sources.length > 0,
|
|
486
|
+
setup_required: sources.length === 0,
|
|
487
|
+
resolved_config: sources.length > 0 ? resolved : null,
|
|
488
|
+
inferred_defaults,
|
|
489
|
+
sources,
|
|
490
|
+
warnings: {
|
|
491
|
+
unknown_fields: unknownFields,
|
|
492
|
+
},
|
|
493
|
+
};
|
|
494
|
+
}
|