@hanna84/mcp-writing 3.3.1 → 3.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 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
+ #### [v3.4.0](https://github.com/hannasdev/mcp-writing.git
8
+ /compare/v3.3.2...v3.4.0)
9
+
10
+ - feat: add client-agnostic styleguide setup contract and VS Code handoff [`#176`](https://github.com/hannasdev/mcp-writing.git
11
+ /pull/176)
12
+
13
+ #### [v3.3.2](https://github.com/hannasdev/mcp-writing.git
14
+ /compare/v3.3.1...v3.3.2)
15
+
16
+ > 5 May 2026
17
+
18
+ - docs: split PRD overview and add client-agnostic setup PRD [`#175`](https://github.com/hannasdev/mcp-writing.git
19
+ /pull/175)
20
+ - Release 3.3.2 [`de30625`](https://github.com/hannasdev/mcp-writing.git
21
+ /commit/de30625c1ff4a25065f124bb26f95397c6db5493)
22
+
7
23
  #### [v3.3.1](https://github.com/hannasdev/mcp-writing.git
8
24
  /compare/v3.3.0...v3.3.1)
9
25
 
26
+ > 3 May 2026
27
+
10
28
  - fix(review-comments): enforce command-specific flags and add failure-path tests [`#174`](https://github.com/hannasdev/mcp-writing.git
11
29
  /pull/174)
30
+ - Release 3.3.1 [`736bc90`](https://github.com/hannasdev/mcp-writing.git
31
+ /commit/736bc90a224885b700b5de331a2f9e333a11f1c8)
12
32
 
13
33
  #### [v3.3.0](https://github.com/hannasdev/mcp-writing.git
14
34
  /compare/v3.2.1...v3.3.0)
package/README.md CHANGED
@@ -16,6 +16,12 @@ WRITING_SYNC_DIR=/path/to/sync-dir DB_PATH=./writing.db npx -y @hanna84/mcp-writ
16
16
 
17
17
  The CLI wrapper defaults to stdio transport and adds the Node 22 SQLite flag automatically when needed.
18
18
 
19
+ ## VS Code extension
20
+
21
+ For VS Code-native setup flows (including prose styleguide setup), use:
22
+
23
+ - [hannasdev/mcp-writing-vscode](https://github.com/hannasdev/mcp-writing-vscode)
24
+
19
25
  ## What it does
20
26
 
21
27
  Instead of feeding an entire manuscript to an AI and hoping it fits in the context window, `mcp-writing` builds a structured index from your scene files. The AI queries that index first — finding relevant characters, beats, and loglines — then loads only the specific prose it needs.
@@ -37,6 +43,7 @@ Instead of feeding an entire manuscript to an AI and hoping it fits in the conte
37
43
  | Guide | Description |
38
44
  |---|---|
39
45
  | [docs/setup.md](docs/setup.md) | Prerequisites, first-time setup, Scrivener import, native sync format |
46
+ | [mcp-writing-vscode](https://github.com/hannasdev/mcp-writing-vscode) | VS Code extension for client-native setup flows |
40
47
  | [docs/docker.md](docs/docker.md) | Docker Compose, OpenClaw integration, SSH hardening |
41
48
  | [docs/data-ownership.md](docs/data-ownership.md) | Which tools write which files, import safety rules |
42
49
  | [docs/tools.md](docs/tools.md) | Full tool reference — auto-generated from source |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanna84/mcp-writing",
3
- "version": "3.3.1",
3
+ "version": "3.4.0",
4
4
  "description": "MCP service for AI-assisted reasoning and editing on long-form fiction projects",
5
5
  "homepage": "https://hannasdev.github.io/mcp-writing/",
6
6
  "type": "module",
@@ -29,6 +29,7 @@
29
29
  "./prose-styleguide.js": "./src/styleguide/prose-styleguide.js",
30
30
  "./prose-styleguide-drift.js": "./src/styleguide/prose-styleguide-drift.js",
31
31
  "./prose-styleguide-skill.js": "./src/styleguide/prose-styleguide-skill.js",
32
+ "./setup-contract.js": "./src/setup/setup-contract.js",
32
33
  "./tools/*": "./src/tools/*",
33
34
  "./scripts/*": "./src/scripts/*",
34
35
  "./src/*": "./src/*",
@@ -45,6 +46,7 @@
45
46
  "src/core/",
46
47
  "src/review-bundles/",
47
48
  "src/runtime/",
49
+ "src/setup/",
48
50
  "src/scripts/",
49
51
  "src/styleguide/",
50
52
  "src/sync/",
@@ -68,7 +70,7 @@
68
70
  "normalize:scene-characters": "node --experimental-sqlite src/scripts/normalize-scene-characters.mjs",
69
71
  "setup:openclaw-env": "sh src/scripts/setup-openclaw-env.sh",
70
72
  "release": "release-it",
71
- "lint": "eslint *.js src/index.js src/core src/review-bundles src/runtime src/scripts src/styleguide src/sync src/tools src/workflows src/world",
73
+ "lint": "eslint *.js src/index.js src/core src/review-bundles src/runtime src/setup src/scripts src/styleguide src/sync src/tools src/workflows src/world",
72
74
  "guard:legacy-root-imports": "node src/scripts/check-legacy-root-imports.mjs",
73
75
  "docs": "node src/scripts/generate-tool-docs.mjs",
74
76
  "lint:metadata": "node src/scripts/lint-metadata.mjs",
package/src/index.js CHANGED
@@ -18,7 +18,18 @@ import {
18
18
  readEntityMetadata,
19
19
  resolveBatchTargetScenes,
20
20
  } from "./core/helpers.js";
21
- import { STYLEGUIDE_CONFIG_BASENAME } from "./styleguide/prose-styleguide.js";
21
+ import { STYLEGUIDE_CONFIG_BASENAME, resolveStyleguideConfig } from "./styleguide/prose-styleguide.js";
22
+ import {
23
+ PROSE_STYLEGUIDE_SKILL_BASENAME,
24
+ PROSE_STYLEGUIDE_SKILL_DIRNAME,
25
+ } from "./styleguide/prose-styleguide-skill.js";
26
+ import {
27
+ loadSetupContract,
28
+ deriveStyleguideSetupStatus,
29
+ resolveStyleguideSetupAnswers,
30
+ buildStyleguideSetupArtifactPlan,
31
+ } from "./setup/setup-contract.js";
32
+ import { validateSetupContractContext } from "./setup/setup-contract-response-schema.js";
22
33
  import { registerSyncTools } from "./tools/sync.js";
23
34
  import { registerSearchTools } from "./tools/search.js";
24
35
  import { registerMetadataTools } from "./tools/metadata.js";
@@ -87,6 +98,7 @@ const MCP_SERVER_VERSION = typeof pkg.version === "string" && pkg.version.trim()
87
98
  ? pkg.version
88
99
  : "0.0.0";
89
100
  const asyncJobs = new Map();
101
+ const styleguideSetupContract = loadSetupContract({ rootDir: ROOT_DIR, contractId: "styleguide_setup_v1" });
90
102
 
91
103
  function paginateRows(rows, { page, pageSize, forcePagination = false }) {
92
104
  const totalCount = rows.length;
@@ -355,6 +367,70 @@ function createMcpServer() {
355
367
  const syncRootExists = fs.existsSync(syncRootConfigPath);
356
368
  const universeRootExists = universeRootConfigPath !== null && fs.existsSync(universeRootConfigPath);
357
369
  const projectRootExists = projectRootConfigPath !== null && fs.existsSync(projectRootConfigPath);
370
+ const styleguideExists = {
371
+ sync_root: syncRootExists,
372
+ universe_root: universeRootExists,
373
+ project_root: projectRootExists,
374
+ };
375
+ const syncRootSkillPath = path.join(
376
+ SYNC_DIR,
377
+ PROSE_STYLEGUIDE_SKILL_DIRNAME,
378
+ PROSE_STYLEGUIDE_SKILL_BASENAME
379
+ );
380
+ const styleguideSkillExists = fs.existsSync(syncRootSkillPath);
381
+
382
+ const styleguideResolution = resolveStyleguideConfig({
383
+ syncDir: SYNC_DIR,
384
+ projectId: project_id ?? undefined,
385
+ });
386
+ const styleguideValid = styleguideResolution.ok;
387
+ const styleguideSetupStatus = deriveStyleguideSetupStatus({
388
+ styleguideExists,
389
+ styleguideValid,
390
+ styleguideSkillExists,
391
+ styleguideEnforcementMode: STYLEGUIDE_ENFORCEMENT_MODE,
392
+ });
393
+ const setupPlanPreview = (() => {
394
+ if (!styleguideSetupContract.ok) return null;
395
+ const resolved = resolveStyleguideSetupAnswers({
396
+ contract: styleguideSetupContract.contract,
397
+ answers: {},
398
+ inferred: { project_id },
399
+ });
400
+ if (!resolved.ok) return null;
401
+ const plan = buildStyleguideSetupArtifactPlan({
402
+ resolvedAnswers: resolved.resolved_answers,
403
+ sceneCount: scene_count,
404
+ setupStatus: styleguideSetupStatus.status,
405
+ });
406
+ return {
407
+ flow_id: "styleguide_setup_v1",
408
+ default_scope: resolved.resolved_answers.scope,
409
+ actions: plan.actions,
410
+ };
411
+ })();
412
+
413
+ const setupContractContext = styleguideSetupContract.ok
414
+ ? {
415
+ contract_id: styleguideSetupContract.contract_id,
416
+ schema_version: styleguideSetupContract.contract.schema_version,
417
+ styleguide_setup_status: styleguideSetupStatus.status,
418
+ setup_recommended: styleguideSetupStatus.setup_recommended,
419
+ ...(setupPlanPreview ? { plan_preview: setupPlanPreview } : {}),
420
+ }
421
+ : {
422
+ contract_id: "styleguide_setup_v1",
423
+ status: "unavailable",
424
+ error_code: styleguideSetupContract.error.code,
425
+ };
426
+ const setupContractContextCheck = validateSetupContractContext(setupContractContext);
427
+ if (!setupContractContextCheck.ok) {
428
+ return errorResponse(
429
+ setupContractContextCheck.error.code,
430
+ setupContractContextCheck.error.message,
431
+ setupContractContextCheck.error.details
432
+ );
433
+ }
358
434
 
359
435
  return jsonResponse({
360
436
  ok: true,
@@ -362,11 +438,8 @@ function createMcpServer() {
362
438
  project_id,
363
439
  scene_count,
364
440
  sync_dir: SYNC_DIR_ABS,
365
- styleguide_exists: {
366
- sync_root: syncRootExists,
367
- universe_root: universeRootExists,
368
- project_root: projectRootExists,
369
- },
441
+ styleguide_exists: styleguideExists,
442
+ setup_contract: setupContractContextCheck.value,
370
443
  git_available: GIT_AVAILABLE,
371
444
  pending_proposals: pendingProposals.size,
372
445
  db_migration_warnings: DB_STARTUP_WARNINGS,
@@ -378,7 +451,8 @@ function createMcpServer() {
378
451
  "If a tool returns a next_step field (in a success or error response), follow it before trying anything else.",
379
452
  "Use find_scenes without filters to discover what project_ids are indexed.",
380
453
  "When calling bootstrap_prose_styleguide_config or check_prose_styleguide_drift, set max_scenes to context.scene_count to avoid the default limit.",
381
- "Styleguide tools resolve config in priority order: project_root > universe_root > sync_root. If any styleguide_exists field is true, a config exists and styleguide tools will work — do not run setup_prose_styleguide_config unless ALL styleguide_exists fields are false.",
454
+ "Use context.setup_contract.styleguide_setup_status to decide whether styleguide setup is missing/invalid and advisory/blocking.",
455
+ "Styleguide tools resolve config in priority order: project_root > universe_root > sync_root. If any styleguide_exists field is true, a config exists and styleguide tools will work. For invalid setup states, use setup_contract plan preview actions (which may set overwrite=true for repair).",
382
456
  ...(DB_STARTUP_WARNINGS.length > 0
383
457
  ? ["Database migration warnings are present in context.db_migration_warnings. Run sync() now, then run enrich_scene(scene_id, project_id) for stale scenes you touch."]
384
458
  : []),
@@ -0,0 +1,146 @@
1
+ {
2
+ "schema_version": "1.0.0",
3
+ "flows": [
4
+ {
5
+ "id": "styleguide_setup_v1",
6
+ "label": "Set up prose styleguide",
7
+ "questions": [
8
+ "scope",
9
+ "project_id",
10
+ "language",
11
+ "bootstrap_from_scenes",
12
+ "high_impact_overrides",
13
+ "voice_notes"
14
+ ],
15
+ "artifact_targets": [
16
+ "prose_styleguide_config",
17
+ "prose_styleguide_skill"
18
+ ],
19
+ "completion_rules": [
20
+ "styleguide_config_exists",
21
+ "styleguide_config_valid",
22
+ "skill_present_when_sync_scope"
23
+ ]
24
+ }
25
+ ],
26
+ "questions": {
27
+ "scope": {
28
+ "label": "Config scope",
29
+ "help_text": "Choose where the prose styleguide config should be written.",
30
+ "blocking": true,
31
+ "ask_mode": "always",
32
+ "input_type": "enum",
33
+ "allowed_values": [
34
+ "project_root",
35
+ "sync_root"
36
+ ],
37
+ "write_target": "setup.scope"
38
+ },
39
+ "project_id": {
40
+ "label": "Project ID",
41
+ "help_text": "Required when scope is project_root.",
42
+ "blocking": true,
43
+ "ask_mode": "inferred_with_confirmation",
44
+ "input_type": "string",
45
+ "write_target": "setup.project_id"
46
+ },
47
+ "language": {
48
+ "label": "Primary language",
49
+ "help_text": "Seeds styleguide defaults.",
50
+ "blocking": true,
51
+ "ask_mode": "always",
52
+ "input_type": "enum",
53
+ "allowed_values": [
54
+ "english_us",
55
+ "english_uk",
56
+ "english_au",
57
+ "english_ca",
58
+ "swedish",
59
+ "norwegian",
60
+ "danish",
61
+ "finnish",
62
+ "french",
63
+ "italian",
64
+ "russian",
65
+ "portuguese_pt",
66
+ "portuguese_br",
67
+ "german",
68
+ "dutch",
69
+ "polish",
70
+ "czech",
71
+ "hungarian",
72
+ "spanish",
73
+ "irish",
74
+ "japanese",
75
+ "korean",
76
+ "chinese_traditional",
77
+ "chinese_simplified"
78
+ ],
79
+ "write_target": "styleguide.language"
80
+ },
81
+ "bootstrap_from_scenes": {
82
+ "label": "Bootstrap from existing scenes",
83
+ "help_text": "Optionally infer likely conventions from manuscript prose before final confirmation.",
84
+ "blocking": false,
85
+ "ask_mode": "low_risk_keep_change",
86
+ "input_type": "boolean",
87
+ "write_target": "setup.bootstrap_from_scenes"
88
+ },
89
+ "high_impact_overrides": {
90
+ "label": "High-impact convention overrides",
91
+ "help_text": "Confirm or override punctuation, numbers, and formatting conventions.",
92
+ "blocking": false,
93
+ "ask_mode": "inferred_with_confirmation",
94
+ "input_type": "object",
95
+ "write_target": "styleguide.overrides"
96
+ },
97
+ "voice_notes": {
98
+ "label": "Voice notes",
99
+ "help_text": "Optional freeform guidance for tone and craft preferences.",
100
+ "blocking": false,
101
+ "ask_mode": "low_risk_keep_change",
102
+ "input_type": "string",
103
+ "write_target": "styleguide.voice_notes"
104
+ }
105
+ },
106
+ "defaults": {
107
+ "scope": "project_root",
108
+ "language": "english_us",
109
+ "bootstrap_from_scenes": true
110
+ },
111
+ "validation_rules": [
112
+ {
113
+ "id": "project_id_required_for_project_root",
114
+ "when": {
115
+ "scope": "project_root"
116
+ },
117
+ "rule": "project_id is required"
118
+ },
119
+ {
120
+ "id": "language_must_be_supported",
121
+ "rule": "language must be one of allowed_values"
122
+ }
123
+ ],
124
+ "artifact_targets": {
125
+ "prose_styleguide_config": {
126
+ "path": "prose-styleguide.config.yaml",
127
+ "owner": "mcp_writing_runtime"
128
+ },
129
+ "prose_styleguide_skill": {
130
+ "path": "skills/prose-styleguide/SKILL.md",
131
+ "owner": "mcp_writing_runtime",
132
+ "condition": "scope == sync_root"
133
+ }
134
+ },
135
+ "completion_rules": {
136
+ "styleguide_config_exists": {
137
+ "derived_from": "filesystem"
138
+ },
139
+ "styleguide_config_valid": {
140
+ "derived_from": "config_validation"
141
+ },
142
+ "skill_present_when_sync_scope": {
143
+ "derived_from": "filesystem_conditional"
144
+ }
145
+ }
146
+ }
@@ -0,0 +1,59 @@
1
+ import { z } from "zod";
2
+
3
+ const actionSchema = z.object({
4
+ tool: z.string().min(1),
5
+ arguments: z.record(z.string(), z.unknown()),
6
+ mode: z.enum(["preview_or_confirm", "write"]).optional(),
7
+ note: z.string().min(1).optional(),
8
+ });
9
+
10
+ const planPreviewSchema = z.object({
11
+ flow_id: z.literal("styleguide_setup_v1"),
12
+ default_scope: z.enum(["project_root", "sync_root"]),
13
+ actions: z.array(actionSchema),
14
+ });
15
+
16
+ export const setupContractContextSchema = z.union([
17
+ z.object({
18
+ contract_id: z.literal("styleguide_setup_v1"),
19
+ schema_version: z.string().min(1),
20
+ styleguide_setup_status: z.enum([
21
+ "missing_advisory",
22
+ "missing_blocking",
23
+ "invalid_advisory",
24
+ "invalid_blocking",
25
+ "complete",
26
+ ]),
27
+ setup_recommended: z.boolean(),
28
+ plan_preview: planPreviewSchema,
29
+ }),
30
+ z.object({
31
+ contract_id: z.literal("styleguide_setup_v1"),
32
+ status: z.literal("unavailable"),
33
+ error_code: z.string().min(1),
34
+ }),
35
+ ]);
36
+
37
+ export function validateSetupContractContext(payload) {
38
+ const parsed = setupContractContextSchema.safeParse(payload);
39
+ if (!parsed.success) {
40
+ return {
41
+ ok: false,
42
+ error: {
43
+ code: "INVALID_SETUP_CONTRACT_CONTEXT",
44
+ message: "describe_workflows produced an invalid setup_contract context payload.",
45
+ details: {
46
+ issues: parsed.error.issues.map((issue) => ({
47
+ path: issue.path.join("."),
48
+ message: issue.message,
49
+ })),
50
+ },
51
+ },
52
+ };
53
+ }
54
+
55
+ return {
56
+ ok: true,
57
+ value: parsed.data,
58
+ };
59
+ }
@@ -0,0 +1,315 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { z } from "zod";
4
+
5
+ const questionSchema = z.object({
6
+ label: z.string().min(1),
7
+ help_text: z.string().min(1),
8
+ blocking: z.boolean(),
9
+ ask_mode: z.enum(["always", "inferred_with_confirmation", "low_risk_keep_change"]),
10
+ input_type: z.enum(["enum", "string", "boolean", "object"]),
11
+ allowed_values: z.array(z.string()).optional(),
12
+ write_target: z.string().min(1),
13
+ });
14
+
15
+ const flowSchema = z.object({
16
+ id: z.string().min(1),
17
+ label: z.string().min(1),
18
+ questions: z.array(z.string()).min(1),
19
+ artifact_targets: z.array(z.string()).min(1),
20
+ completion_rules: z.array(z.string()).min(1),
21
+ });
22
+
23
+ export const setupContractSchema = z.object({
24
+ schema_version: z.string().min(1),
25
+ flows: z.array(flowSchema).min(1),
26
+ questions: z.record(z.string(), questionSchema),
27
+ defaults: z.record(z.string(), z.union([z.string(), z.boolean()])),
28
+ validation_rules: z.array(z.object({
29
+ id: z.string().min(1),
30
+ when: z.record(z.string(), z.string()).optional(),
31
+ rule: z.string().min(1),
32
+ })),
33
+ artifact_targets: z.record(z.string(), z.object({
34
+ path: z.string().min(1),
35
+ owner: z.string().min(1),
36
+ condition: z.string().optional(),
37
+ })),
38
+ completion_rules: z.record(z.string(), z.object({
39
+ derived_from: z.enum(["filesystem", "config_validation", "filesystem_conditional"]),
40
+ })),
41
+ });
42
+
43
+ const CONTRACT_RELATIVE_PATHS = {
44
+ styleguide_setup_v1: path.join("src", "setup", "contracts", "styleguide_setup_v1.json"),
45
+ };
46
+
47
+ function getFlowById(contract, flowId) {
48
+ return contract.flows.find((flow) => flow.id === flowId) ?? null;
49
+ }
50
+
51
+ function parseJsonFile(filePath) {
52
+ const raw = fs.readFileSync(filePath, "utf8");
53
+ return JSON.parse(raw);
54
+ }
55
+
56
+ export function loadSetupContract({ rootDir, contractId = "styleguide_setup_v1" }) {
57
+ const relativePath = CONTRACT_RELATIVE_PATHS[contractId];
58
+ if (!relativePath) {
59
+ return {
60
+ ok: false,
61
+ error: {
62
+ code: "SETUP_CONTRACT_NOT_FOUND",
63
+ message: `Unknown setup contract id: ${contractId}`,
64
+ details: { contract_id: contractId },
65
+ },
66
+ };
67
+ }
68
+
69
+ const contractPath = path.join(rootDir, relativePath);
70
+ if (!fs.existsSync(contractPath)) {
71
+ return {
72
+ ok: false,
73
+ error: {
74
+ code: "SETUP_CONTRACT_FILE_MISSING",
75
+ message: `Setup contract file not found: ${path.resolve(contractPath)}`,
76
+ details: { contract_id: contractId, contract_path: path.resolve(contractPath) },
77
+ },
78
+ };
79
+ }
80
+
81
+ let parsed;
82
+ try {
83
+ parsed = parseJsonFile(contractPath);
84
+ } catch (error) {
85
+ return {
86
+ ok: false,
87
+ error: {
88
+ code: "SETUP_CONTRACT_INVALID_JSON",
89
+ message: `Failed to parse setup contract JSON: ${error instanceof Error ? error.message : String(error)}`,
90
+ details: { contract_id: contractId, contract_path: path.resolve(contractPath) },
91
+ },
92
+ };
93
+ }
94
+
95
+ const validated = setupContractSchema.safeParse(parsed);
96
+ if (!validated.success) {
97
+ return {
98
+ ok: false,
99
+ error: {
100
+ code: "SETUP_CONTRACT_SCHEMA_INVALID",
101
+ message: "Setup contract does not match required schema.",
102
+ details: {
103
+ contract_id: contractId,
104
+ contract_path: path.resolve(contractPath),
105
+ issues: validated.error.issues.map((issue) => ({
106
+ path: issue.path.join("."),
107
+ message: issue.message,
108
+ })),
109
+ },
110
+ },
111
+ };
112
+ }
113
+
114
+ return {
115
+ ok: true,
116
+ contract_id: contractId,
117
+ contract_path: path.resolve(contractPath),
118
+ contract: validated.data,
119
+ };
120
+ }
121
+
122
+ export function resolveStyleguideSetupAnswers({
123
+ contract,
124
+ flowId = "styleguide_setup_v1",
125
+ answers = {},
126
+ inferred = {},
127
+ }) {
128
+ const flow = getFlowById(contract, flowId);
129
+ if (!flow) {
130
+ return {
131
+ ok: false,
132
+ error: {
133
+ code: "SETUP_FLOW_NOT_FOUND",
134
+ message: `Flow not found in setup contract: ${flowId}`,
135
+ details: { flow_id: flowId },
136
+ },
137
+ };
138
+ }
139
+
140
+ const defaultScope = inferred.project_id
141
+ ? (contract.defaults.scope ?? "project_root")
142
+ : "sync_root";
143
+ const scope = answers.scope ?? defaultScope;
144
+ const language = answers.language ?? contract.defaults.language;
145
+ const bootstrapFromScenes = answers.bootstrap_from_scenes ?? contract.defaults.bootstrap_from_scenes;
146
+ const projectId = answers.project_id ?? inferred.project_id ?? null;
147
+ const overrides = answers.high_impact_overrides ?? {};
148
+ const voiceNotes = answers.voice_notes;
149
+
150
+ if (typeof bootstrapFromScenes !== "boolean") {
151
+ return {
152
+ ok: false,
153
+ error: {
154
+ code: "INVALID_SETUP_BOOTSTRAP_FLAG",
155
+ message: "bootstrap_from_scenes must be a boolean.",
156
+ details: {
157
+ bootstrap_from_scenes: bootstrapFromScenes,
158
+ },
159
+ },
160
+ };
161
+ }
162
+
163
+ const scopeQuestion = contract.questions.scope;
164
+ if (!scopeQuestion.allowed_values?.includes(scope)) {
165
+ return {
166
+ ok: false,
167
+ error: {
168
+ code: "INVALID_SETUP_SCOPE",
169
+ message: "scope must be one of the setup contract allowed_values.",
170
+ details: {
171
+ scope,
172
+ allowed_values: scopeQuestion.allowed_values ?? [],
173
+ },
174
+ },
175
+ };
176
+ }
177
+
178
+ const languageQuestion = contract.questions.language;
179
+ if (!languageQuestion.allowed_values?.includes(language)) {
180
+ return {
181
+ ok: false,
182
+ error: {
183
+ code: "INVALID_SETUP_LANGUAGE",
184
+ message: "language must be one of the setup contract allowed_values.",
185
+ details: {
186
+ language,
187
+ allowed_values: languageQuestion.allowed_values ?? [],
188
+ },
189
+ },
190
+ };
191
+ }
192
+
193
+ if (scope === "project_root" && !projectId) {
194
+ return {
195
+ ok: false,
196
+ error: {
197
+ code: "SETUP_PROJECT_ID_REQUIRED",
198
+ message: "project_id is required when scope is project_root.",
199
+ details: { scope, project_id: projectId },
200
+ },
201
+ };
202
+ }
203
+
204
+ return {
205
+ ok: true,
206
+ flow,
207
+ resolved_answers: {
208
+ scope,
209
+ project_id: projectId,
210
+ language,
211
+ bootstrap_from_scenes: bootstrapFromScenes,
212
+ high_impact_overrides: overrides,
213
+ voice_notes: typeof voiceNotes === "string" && voiceNotes.trim().length > 0
214
+ ? voiceNotes
215
+ : undefined,
216
+ },
217
+ };
218
+ }
219
+
220
+ export function buildStyleguideSetupArtifactPlan({
221
+ resolvedAnswers,
222
+ sceneCount = 0,
223
+ setupStatus = "missing_advisory",
224
+ }) {
225
+ const replaceExistingArtifacts = typeof setupStatus === "string" && setupStatus.startsWith("invalid_");
226
+ const configAction = {
227
+ tool: "setup_prose_styleguide_config",
228
+ arguments: {
229
+ scope: resolvedAnswers.scope,
230
+ language: resolvedAnswers.language,
231
+ ...(resolvedAnswers.project_id ? { project_id: resolvedAnswers.project_id } : {}),
232
+ ...(Object.keys(resolvedAnswers.high_impact_overrides ?? {}).length > 0
233
+ ? { overrides: resolvedAnswers.high_impact_overrides }
234
+ : {}),
235
+ ...(resolvedAnswers.voice_notes ? { voice_notes: resolvedAnswers.voice_notes } : {}),
236
+ overwrite: replaceExistingArtifacts,
237
+ },
238
+ mode: "write",
239
+ };
240
+
241
+ const actions = [];
242
+ if (resolvedAnswers.bootstrap_from_scenes && resolvedAnswers.project_id) {
243
+ actions.push({
244
+ tool: "bootstrap_prose_styleguide_config",
245
+ arguments: {
246
+ project_id: resolvedAnswers.project_id,
247
+ max_scenes: Math.max(1, sceneCount || 1),
248
+ },
249
+ mode: "preview_or_confirm",
250
+ note: "Use bootstrap suggested_config to populate setup_prose_styleguide_config.overrides before executing write actions.",
251
+ });
252
+ }
253
+
254
+ actions.push(configAction);
255
+
256
+ if (resolvedAnswers.scope === "sync_root") {
257
+ actions.push({
258
+ tool: "setup_prose_styleguide_skill",
259
+ arguments: { overwrite: true },
260
+ mode: "write",
261
+ });
262
+ }
263
+
264
+ return {
265
+ ok: true,
266
+ artifact_targets: [
267
+ "prose-styleguide.config.yaml",
268
+ ...(resolvedAnswers.scope === "sync_root" ? ["skills/prose-styleguide/SKILL.md"] : []),
269
+ ],
270
+ actions,
271
+ };
272
+ }
273
+
274
+ export function deriveStyleguideSetupStatus({
275
+ styleguideExists,
276
+ styleguideValid,
277
+ styleguideSkillExists,
278
+ styleguideEnforcementMode,
279
+ }) {
280
+ const anyStyleguideExists = Boolean(
281
+ styleguideExists?.sync_root
282
+ || styleguideExists?.universe_root
283
+ || styleguideExists?.project_root
284
+ );
285
+ const isBlockingMode = styleguideEnforcementMode === "required";
286
+
287
+ if (!anyStyleguideExists) {
288
+ return {
289
+ status: isBlockingMode ? "missing_blocking" : "missing_advisory",
290
+ setup_recommended: true,
291
+ };
292
+ }
293
+
294
+ if (!styleguideValid) {
295
+ return {
296
+ status: isBlockingMode ? "invalid_blocking" : "invalid_advisory",
297
+ setup_recommended: true,
298
+ };
299
+ }
300
+
301
+ const requiresSyncRootSkill = Boolean(styleguideExists?.sync_root)
302
+ && !styleguideExists?.universe_root
303
+ && !styleguideExists?.project_root;
304
+ if (requiresSyncRootSkill && !styleguideSkillExists) {
305
+ return {
306
+ status: isBlockingMode ? "invalid_blocking" : "invalid_advisory",
307
+ setup_recommended: true,
308
+ };
309
+ }
310
+
311
+ return {
312
+ status: "complete",
313
+ setup_recommended: false,
314
+ };
315
+ }