@hanna84/mcp-writing 2.9.4 → 2.9.6

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
+ #### [v2.9.6](https://github.com/hannasdev/mcp-writing.git
8
+ /compare/v2.9.5...v2.9.6)
9
+
10
+ - refactor(tools): extract registerReviewBundleTools into tools/review-bundles.js [`#99`](https://github.com/hannasdev/mcp-writing.git
11
+ /pull/99)
12
+
13
+ #### [v2.9.5](https://github.com/hannasdev/mcp-writing.git
14
+ /compare/v2.9.4...v2.9.5)
15
+
16
+ > 26 April 2026
17
+
18
+ - refactor(tools): extract registerMetadataTools into tools/metadata.js [`#98`](https://github.com/hannasdev/mcp-writing.git
19
+ /pull/98)
20
+ - Release 2.9.5 [`85c4bd5`](https://github.com/hannasdev/mcp-writing.git
21
+ /commit/85c4bd5db34bc51c92a69be7af6285f8934959b7)
22
+
7
23
  #### [v2.9.4](https://github.com/hannasdev/mcp-writing.git
8
24
  /compare/v2.9.3...v2.9.4)
9
25
 
26
+ > 26 April 2026
27
+
10
28
  - fix(ci): update publish workflow to use npm test [`#97`](https://github.com/hannasdev/mcp-writing.git
11
29
  /pull/97)
30
+ - Release 2.9.4 [`576f259`](https://github.com/hannasdev/mcp-writing.git
31
+ /commit/576f2591907bfed039a4fe7f8cc9ae74f78d0eed)
12
32
 
13
33
  #### [v2.9.3](https://github.com/hannasdev/mcp-writing.git
14
34
  /compare/v2.9.2...v2.9.3)
package/index.js CHANGED
@@ -12,10 +12,10 @@ import matter from "gray-matter";
12
12
  import yaml from "js-yaml";
13
13
  import { z } from "zod";
14
14
  import { openDb } from "./db.js";
15
- import { syncAll, isSyncDirWritable, getSyncOwnershipDiagnostics, getFileWriteDiagnostics, writeMeta, readMeta, indexSceneFile, normalizeSceneMetaForPath, sidecarPath, isStructuralProjectId } from "./sync.js";
16
- import { isGitAvailable, isGitRepository, initGitRepository, createSnapshot, listSnapshots, getSceneProseAtCommit, getHeadCommitHash } from "./git.js";
15
+ import { syncAll, isSyncDirWritable, getSyncOwnershipDiagnostics, getFileWriteDiagnostics, readMeta, indexSceneFile, sidecarPath, isStructuralProjectId } from "./sync.js";
16
+ import { isGitAvailable, isGitRepository, initGitRepository, createSnapshot, listSnapshots, getSceneProseAtCommit } from "./git.js";
17
17
  import { renderCharacterArcTemplate, renderCharacterSheetTemplate, renderPlaceSheetTemplate, slugifyEntityName } from "./world-entity-templates.js";
18
- import { validateProjectId, validateUniverseId } from "./importer.js";
18
+ import { validateProjectId } from "./importer.js";
19
19
  import { ASYNC_PROGRESS_PREFIX } from "./async-progress.js";
20
20
  import {
21
21
  STYLEGUIDE_CONFIG_BASENAME,
@@ -36,15 +36,11 @@ import {
36
36
  PROSE_STYLEGUIDE_SKILL_DIRNAME,
37
37
  buildProseStyleguideSkill,
38
38
  } from "./prose-styleguide-skill.js";
39
- import {
40
- REVIEW_BUNDLE_PROFILES,
41
- REVIEW_BUNDLE_STRICTNESS,
42
- ReviewBundlePlanError,
43
- buildReviewBundlePlan,
44
- createReviewBundleArtifacts,
45
- } from "./review-bundles.js";
39
+ import { ReviewBundlePlanError } from "./review-bundles.js";
46
40
  import { registerSyncTools } from "./tools/sync.js";
47
41
  import { registerSearchTools } from "./tools/search.js";
42
+ import { registerMetadataTools } from "./tools/metadata.js";
43
+ import { registerReviewBundleTools } from "./tools/review-bundles.js";
48
44
 
49
45
  const SYNC_DIR = process.env.WRITING_SYNC_DIR ?? "./sync";
50
46
  const DB_PATH = process.env.DB_PATH ?? "./writing.db";
@@ -1041,9 +1037,13 @@ function createMcpServer() {
1041
1037
  getSceneProseAtCommit,
1042
1038
  readSupportingNotesForEntity,
1043
1039
  readEntityMetadata,
1040
+ createCanonicalWorldEntity,
1041
+ resolveOutputDirWithinSync,
1044
1042
  };
1045
1043
  registerSyncTools(s, toolContext);
1046
1044
  registerSearchTools(s, toolContext);
1045
+ registerMetadataTools(s, toolContext);
1046
+ registerReviewBundleTools(s, toolContext);
1047
1047
 
1048
1048
  // ---- get_runtime_config --------------------------------------------------
1049
1049
  s.tool(
@@ -1717,525 +1717,6 @@ function createMcpServer() {
1717
1717
  }
1718
1718
  );
1719
1719
 
1720
- // ---- preview_review_bundle ----------------------------------------------
1721
- s.tool(
1722
- "preview_review_bundle",
1723
- "Dry-run planning tool for review bundles. Resolves scene scope, deterministic ordering, warnings, and planned output filenames without writing files. Rendering options are accepted for API consistency and reflected in resolved_scope.options, but do not change planning output.",
1724
- {
1725
- project_id: z.string().describe("Project ID to scope the review bundle (e.g. 'test-novel')."),
1726
- profile: z.enum(REVIEW_BUNDLE_PROFILES).describe("Bundle profile: outline_discussion, editor_detailed, or beta_reader_personalized."),
1727
- part: z.number().int().optional().describe("Optional part filter."),
1728
- chapter: z.number().int().optional().describe("Optional chapter filter."),
1729
- tag: z.string().optional().describe("Optional tag filter (exact match)."),
1730
- scene_ids: z.array(z.string()).optional().describe("Optional explicit scene_id allowlist. Intersects with other filters."),
1731
- strictness: z.enum(REVIEW_BUNDLE_STRICTNESS).optional().describe("Strictness mode: warn (default) or fail."),
1732
- include_scene_ids: z.boolean().optional().describe("Rendering option (default true). Echoed in resolved_scope.options for downstream rendering; does not change planning results."),
1733
- include_metadata_sidebar: z.boolean().optional().describe("Rendering option (default false). Echoed in resolved_scope.options for downstream rendering; does not change planning results."),
1734
- include_paragraph_anchors: z.boolean().optional().describe("Rendering option (default false). Echoed in resolved_scope.options for downstream rendering; does not change planning results."),
1735
- recipient_name: z.string().optional().describe("Optional recipient display name for beta_reader_personalized profile."),
1736
- bundle_name: z.string().optional().describe("Optional output bundle base name override (slugified in planned outputs)."),
1737
- format: z.enum(["pdf", "markdown", "both"]).optional().describe("Planned output format: pdf (default), markdown, or both. Affects planned_outputs filenames only; preview_review_bundle does not render artifacts."),
1738
- },
1739
- async ({
1740
- project_id,
1741
- profile,
1742
- part,
1743
- chapter,
1744
- tag,
1745
- scene_ids,
1746
- strictness = "warn",
1747
- include_scene_ids = true,
1748
- include_metadata_sidebar = false,
1749
- include_paragraph_anchors = false,
1750
- recipient_name,
1751
- bundle_name,
1752
- format = "pdf",
1753
- }) => {
1754
- const projectIdCheck = validateProjectId(project_id);
1755
- if (!projectIdCheck.ok) {
1756
- return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
1757
- }
1758
-
1759
- try {
1760
- const plan = buildReviewBundlePlan(db, {
1761
- project_id,
1762
- profile,
1763
- part,
1764
- chapter,
1765
- tag,
1766
- scene_ids,
1767
- strictness,
1768
- include_scene_ids,
1769
- include_metadata_sidebar,
1770
- include_paragraph_anchors,
1771
- recipient_name,
1772
- bundle_name,
1773
- format,
1774
- });
1775
- return jsonResponse(plan);
1776
- } catch (error) {
1777
- if (error instanceof ReviewBundlePlanError) {
1778
- return errorResponse(error.code, error.message, error.details);
1779
- }
1780
- return errorResponse(
1781
- "PREVIEW_FAILED",
1782
- error instanceof Error ? error.message : "Failed to generate review bundle preview."
1783
- );
1784
- }
1785
- }
1786
- );
1787
-
1788
- // ---- create_review_bundle -----------------------------------------------
1789
- s.tool(
1790
- "create_review_bundle",
1791
- "Generate review bundle artifacts (PDF/markdown) from planned scene scope. Writes files only under output_dir and returns manifest/provenance details.",
1792
- {
1793
- project_id: z.string().describe("Project ID to scope the review bundle (e.g. 'test-novel')."),
1794
- profile: z.enum(REVIEW_BUNDLE_PROFILES).describe("Bundle profile: outline_discussion, editor_detailed, or beta_reader_personalized."),
1795
- output_dir: z.string().describe("Directory path to write bundle artifacts into."),
1796
- part: z.number().int().optional().describe("Optional part filter."),
1797
- chapter: z.number().int().optional().describe("Optional chapter filter."),
1798
- tag: z.string().optional().describe("Optional tag filter (exact match)."),
1799
- scene_ids: z.array(z.string()).optional().describe("Optional explicit scene_id allowlist. Intersects with other filters."),
1800
- strictness: z.enum(REVIEW_BUNDLE_STRICTNESS).optional().describe("Strictness mode: warn (default) or fail."),
1801
- include_scene_ids: z.boolean().optional().describe("Include scene IDs in headings (default true). Applies to both PDF and markdown."),
1802
- include_metadata_sidebar: z.boolean().optional().describe("Include metadata sidebar in markdown output (default false). Markdown only — no effect on PDF."),
1803
- include_paragraph_anchors: z.boolean().optional().describe("Include paragraph anchors in markdown output (default false). Markdown only — no effect on PDF."),
1804
- recipient_name: z.string().optional().describe("Optional recipient display name for beta_reader_personalized profile."),
1805
- bundle_name: z.string().optional().describe("Optional output bundle base name override (slugified in filenames)."),
1806
- source_commit: z.string().optional().describe("Optional explicit source commit for provenance. Defaults to current HEAD when available."),
1807
- format: z.enum(["pdf", "markdown", "both"]).optional().describe("Output format: pdf (default), markdown, or both."),
1808
- },
1809
- async ({
1810
- project_id,
1811
- profile,
1812
- output_dir,
1813
- part,
1814
- chapter,
1815
- tag,
1816
- scene_ids,
1817
- strictness = "warn",
1818
- include_scene_ids = true,
1819
- include_metadata_sidebar = false,
1820
- include_paragraph_anchors = false,
1821
- recipient_name,
1822
- bundle_name,
1823
- source_commit,
1824
- format = "pdf",
1825
- }) => {
1826
- const projectIdCheck = validateProjectId(project_id);
1827
- if (!projectIdCheck.ok) {
1828
- return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
1829
- }
1830
-
1831
- try {
1832
- const { resolvedOutputDir, relativeToSyncDir } = resolveOutputDirWithinSync(output_dir);
1833
- const outputDirSegments = relativeToSyncDir
1834
- .split(path.sep)
1835
- .filter(Boolean)
1836
- .map(segment => segment.toLowerCase());
1837
- if (outputDirSegments.includes("scenes")) {
1838
- return errorResponse(
1839
- "INVALID_OUTPUT_DIR",
1840
- "output_dir cannot be inside a scenes directory. Choose a dedicated export folder under WRITING_SYNC_DIR.",
1841
- { output_dir: resolvedOutputDir }
1842
- );
1843
- }
1844
-
1845
- const plan = buildReviewBundlePlan(db, {
1846
- project_id,
1847
- profile,
1848
- part,
1849
- chapter,
1850
- tag,
1851
- scene_ids,
1852
- strictness,
1853
- include_scene_ids,
1854
- include_metadata_sidebar,
1855
- include_paragraph_anchors,
1856
- recipient_name,
1857
- bundle_name,
1858
- format,
1859
- });
1860
-
1861
- if (!plan.strictness_result.can_proceed) {
1862
- return errorResponse(
1863
- "STRICTNESS_BLOCKED",
1864
- "Bundle generation blocked by strictness policy.",
1865
- { strictness_result: plan.strictness_result, warning_summary: plan.warning_summary }
1866
- );
1867
- }
1868
-
1869
- const provenanceCommit = source_commit ?? (GIT_ENABLED ? getHeadCommitHash(SYNC_DIR) : null);
1870
- const artifacts = await createReviewBundleArtifacts(db, {
1871
- plan,
1872
- output_dir: resolvedOutputDir,
1873
- source_commit: provenanceCommit,
1874
- syncDir: SYNC_DIR_ABS,
1875
- });
1876
-
1877
- return jsonResponse({
1878
- ok: true,
1879
- bundle_id: artifacts.bundle_id,
1880
- output_paths: artifacts.output_paths,
1881
- summary: {
1882
- scene_count: plan.summary.scene_count,
1883
- profile: plan.profile,
1884
- applied_filters: plan.resolved_scope.filters,
1885
- },
1886
- warnings: plan.warnings,
1887
- warning_summary: plan.warning_summary,
1888
- provenance: {
1889
- source_commit: provenanceCommit,
1890
- generated_at: artifacts.generated_at,
1891
- project_id: plan.resolved_scope.project_id,
1892
- },
1893
- });
1894
- } catch (error) {
1895
- if (error instanceof ReviewBundlePlanError) {
1896
- return errorResponse(error.code, error.message, error.details);
1897
- }
1898
- return errorResponse(
1899
- "CREATE_BUNDLE_FAILED",
1900
- error instanceof Error ? error.message : "Failed to create review bundle artifacts."
1901
- );
1902
- }
1903
- }
1904
- );
1905
-
1906
- // ---- create_character_sheet ---------------------------------------------
1907
- s.tool(
1908
- "create_character_sheet",
1909
- "Create or reuse a canonical character sheet folder with sheet.md and sheet.meta.yaml so the character can be indexed immediately. If the folder already exists, missing canonical files are backfilled and the existing sheet is preserved.",
1910
- {
1911
- name: z.string().describe("Display name of the character (e.g. 'Mira Nystrom')."),
1912
- project_id: z.string().optional().describe("Project scope for a book-local character (e.g. 'universe-1/book-1-the-lamb' or 'test-novel')."),
1913
- universe_id: z.string().optional().describe("Universe scope for a cross-book shared character (e.g. 'universe-1')."),
1914
- notes: z.string().optional().describe("Optional starter prose content for sheet.md."),
1915
- fields: z.object({
1916
- role: z.string().optional(),
1917
- arc_summary: z.string().optional(),
1918
- first_appearance: z.string().optional(),
1919
- traits: z.array(z.string()).optional(),
1920
- }).optional().describe("Optional starter metadata fields for the character sidecar."),
1921
- },
1922
- async ({ name, project_id, universe_id, notes, fields }) => {
1923
- if (!SYNC_DIR_WRITABLE) {
1924
- return errorResponse("READ_ONLY", "Cannot create character sheet: sync dir is read-only.");
1925
- }
1926
- const hasProjectId = project_id !== undefined;
1927
- const hasUniverseId = universe_id !== undefined;
1928
- if ((hasProjectId && hasUniverseId) || (!hasProjectId && !hasUniverseId)) {
1929
- return errorResponse("VALIDATION_ERROR", "Provide exactly one of project_id or universe_id.");
1930
- }
1931
- if (hasProjectId) {
1932
- const check = validateProjectId(project_id);
1933
- if (!check.ok) return errorResponse("INVALID_PROJECT_ID", check.reason, { project_id });
1934
- }
1935
- if (hasUniverseId) {
1936
- const check = validateUniverseId(universe_id);
1937
- if (!check.ok) return errorResponse("INVALID_UNIVERSE_ID", check.reason, { universe_id });
1938
- }
1939
-
1940
- try {
1941
- const result = createCanonicalWorldEntity({
1942
- kind: "character",
1943
- name,
1944
- notes,
1945
- projectId: project_id,
1946
- universeId: universe_id,
1947
- meta: fields ?? {},
1948
- });
1949
-
1950
- return jsonResponse({ ok: true, action: result.created ? "created" : "exists", kind: "character", ...result });
1951
- } catch (err) {
1952
- return errorResponse("IO_ERROR", `Failed to create character sheet: ${err.message}`);
1953
- }
1954
- }
1955
- );
1956
-
1957
- // ---- create_place_sheet -------------------------------------------------
1958
- s.tool(
1959
- "create_place_sheet",
1960
- "Create or reuse a canonical place sheet folder with sheet.md and sheet.meta.yaml so the place can be indexed immediately. If the folder already exists, missing canonical files are backfilled and the existing sheet is preserved.",
1961
- {
1962
- name: z.string().describe("Display name of the place (e.g. 'University Hospital')."),
1963
- project_id: z.string().optional().describe("Project scope for a book-local place (e.g. 'universe-1/book-1-the-lamb' or 'test-novel')."),
1964
- universe_id: z.string().optional().describe("Universe scope for a cross-book shared place (e.g. 'universe-1')."),
1965
- notes: z.string().optional().describe("Optional starter prose content for sheet.md."),
1966
- fields: z.object({
1967
- associated_characters: z.array(z.string()).optional(),
1968
- tags: z.array(z.string()).optional(),
1969
- }).optional().describe("Optional starter metadata fields for the place sidecar."),
1970
- },
1971
- async ({ name, project_id, universe_id, notes, fields }) => {
1972
- if (!SYNC_DIR_WRITABLE) {
1973
- return errorResponse("READ_ONLY", "Cannot create place sheet: sync dir is read-only.");
1974
- }
1975
- const hasProjectId = project_id !== undefined;
1976
- const hasUniverseId = universe_id !== undefined;
1977
- if ((hasProjectId && hasUniverseId) || (!hasProjectId && !hasUniverseId)) {
1978
- return errorResponse("VALIDATION_ERROR", "Provide exactly one of project_id or universe_id.");
1979
- }
1980
- if (hasProjectId) {
1981
- const check = validateProjectId(project_id);
1982
- if (!check.ok) return errorResponse("INVALID_PROJECT_ID", check.reason, { project_id });
1983
- }
1984
- if (hasUniverseId) {
1985
- const check = validateUniverseId(universe_id);
1986
- if (!check.ok) return errorResponse("INVALID_UNIVERSE_ID", check.reason, { universe_id });
1987
- }
1988
-
1989
- try {
1990
- const result = createCanonicalWorldEntity({
1991
- kind: "place",
1992
- name,
1993
- notes,
1994
- projectId: project_id,
1995
- universeId: universe_id,
1996
- meta: fields ?? {},
1997
- });
1998
-
1999
- return jsonResponse({ ok: true, action: result.created ? "created" : "exists", kind: "place", ...result });
2000
- } catch (err) {
2001
- return errorResponse("IO_ERROR", `Failed to create place sheet: ${err.message}`);
2002
- }
2003
- }
2004
- );
2005
-
2006
- // ---- upsert_thread_link ---------------------------------------------------
2007
- s.tool(
2008
- "upsert_thread_link",
2009
- "Create or update a thread and link it to a scene. Idempotent: if the link already exists, updates its beat. Only available when the sync dir is writable.",
2010
- {
2011
- project_id: z.string().describe("Project the thread belongs to (e.g. 'the-lamb')."),
2012
- thread_id: z.string().describe("Thread ID (e.g. 'thread-reconciliation')."),
2013
- thread_name: z.string().describe("Thread display name."),
2014
- scene_id: z.string().describe("Scene to link to the thread (e.g. 'sc-011-sebastian')."),
2015
- beat: z.string().optional().describe("Optional thread-specific beat label for this scene."),
2016
- status: z.string().optional().describe("Thread status (e.g. 'active', 'resolved'). Defaults to 'active'."),
2017
- },
2018
- async ({ project_id, thread_id, thread_name, scene_id, beat, status }) => {
2019
- if (!SYNC_DIR_WRITABLE) {
2020
- return errorResponse("READ_ONLY", "Cannot write thread links: sync dir is read-only.");
2021
- }
2022
-
2023
- const existingThread = db.prepare(`SELECT thread_id, project_id FROM threads WHERE thread_id = ?`).get(thread_id);
2024
- if (existingThread && existingThread.project_id !== project_id) {
2025
- return errorResponse(
2026
- "CONFLICT",
2027
- `Thread '${thread_id}' already exists in project '${existingThread.project_id}', cannot reuse it for project '${project_id}'.`
2028
- );
2029
- }
2030
-
2031
- const scene = db.prepare(`SELECT scene_id FROM scenes WHERE scene_id = ? AND project_id = ?`).get(scene_id, project_id);
2032
- if (!scene) {
2033
- return errorResponse("NOT_FOUND", `Scene '${scene_id}' not found in project '${project_id}'.`);
2034
- }
2035
-
2036
- db.prepare(`
2037
- INSERT INTO threads (thread_id, project_id, name, status)
2038
- VALUES (?, ?, ?, ?)
2039
- ON CONFLICT (thread_id) DO UPDATE SET
2040
- name = excluded.name,
2041
- status = excluded.status
2042
- `).run(thread_id, project_id, thread_name, status ?? "active");
2043
-
2044
- db.prepare(`
2045
- INSERT INTO scene_threads (scene_id, thread_id, beat)
2046
- VALUES (?, ?, ?)
2047
- ON CONFLICT (scene_id, thread_id) DO UPDATE SET
2048
- beat = excluded.beat
2049
- `).run(scene_id, thread_id, beat ?? null);
2050
-
2051
- const thread = db.prepare(`SELECT * FROM threads WHERE thread_id = ?`).get(thread_id);
2052
- const link = db.prepare(`SELECT scene_id, thread_id, beat FROM scene_threads WHERE scene_id = ? AND thread_id = ?`)
2053
- .get(scene_id, thread_id);
2054
-
2055
- return jsonResponse({
2056
- ok: true,
2057
- action: "upserted",
2058
- thread,
2059
- link,
2060
- });
2061
- }
2062
- );
2063
-
2064
- // ---- update_scene_metadata -----------------------------------------------
2065
- s.tool(
2066
- "update_scene_metadata",
2067
- "Update one or more metadata fields for a scene. Writes to the .meta.yaml sidecar — never modifies prose. Changes are immediately reflected in the index. Only available when the sync dir is writable.",
2068
- {
2069
- scene_id: z.string().describe("The scene_id to update (e.g. 'sc-011-sebastian')."),
2070
- project_id: z.string().describe("Project the scene belongs to (e.g. 'the-lamb')."),
2071
- fields: z.object({
2072
- title: z.string().optional(),
2073
- logline: z.string().optional(),
2074
- status: z.string().optional().describe("Workflow status (e.g. 'draft', 'revision', 'complete'). Free text — no fixed vocabulary."),
2075
- save_the_cat_beat: z.string().optional(),
2076
- pov: z.string().optional(),
2077
- part: z.number().int().optional(),
2078
- chapter: z.number().int().optional(),
2079
- timeline_position: z.number().int().optional(),
2080
- story_time: z.string().optional(),
2081
- tags: z.array(z.string()).optional(),
2082
- characters: z.array(z.string()).optional(),
2083
- places: z.array(z.string()).optional(),
2084
- }).describe("Fields to update. Only supplied keys are changed."),
2085
- },
2086
- async ({ scene_id, project_id, fields }) => {
2087
- if (!SYNC_DIR_WRITABLE) {
2088
- return errorResponse("READ_ONLY", "Cannot update metadata: sync dir is read-only.");
2089
- }
2090
- const scene = db.prepare(`SELECT file_path FROM scenes WHERE scene_id = ? AND project_id = ?`)
2091
- .get(scene_id, project_id);
2092
- if (!scene) {
2093
- return errorResponse("NOT_FOUND", `Scene '${scene_id}' not found in project '${project_id}'.`);
2094
- }
2095
- try {
2096
- const { meta } = readMeta(scene.file_path, SYNC_DIR, { writable: true });
2097
- const updated = normalizeSceneMetaForPath(SYNC_DIR, scene.file_path, { ...meta, ...fields }).meta;
2098
- writeMeta(scene.file_path, updated);
2099
-
2100
- // Re-index the scene immediately so the DB reflects the new metadata
2101
- const { content: prose } = matter(fs.readFileSync(scene.file_path, "utf8"));
2102
- indexSceneFile(db, SYNC_DIR, scene.file_path, updated, prose);
2103
-
2104
- return { content: [{ type: "text", text: `Updated metadata for scene '${scene_id}'.` }] };
2105
- } catch (err) {
2106
- if (err.code === "ENOENT") {
2107
- return errorResponse("STALE_PATH", `Prose file for scene '${scene_id}' not found at indexed path — the file may have moved. Run sync() to refresh.`, { indexed_path: scene.file_path });
2108
- }
2109
- return errorResponse("IO_ERROR", `Failed to write metadata for scene '${scene_id}': ${err.message}`);
2110
- }
2111
- }
2112
- );
2113
-
2114
- // ---- update_character_sheet ----------------------------------------------
2115
- s.tool(
2116
- "update_character_sheet",
2117
- "Update structured metadata fields for a character (role, arc_summary, traits, etc). Writes to the .meta.yaml sidecar — never modifies the prose notes file. Changes are immediately reflected in the index. Only available when the sync dir is writable.",
2118
- {
2119
- character_id: z.string().describe("The character_id to update (e.g. 'char-mira-nystrom'). Use list_characters to find valid IDs."),
2120
- fields: z.object({
2121
- name: z.string().optional(),
2122
- role: z.string().optional(),
2123
- arc_summary: z.string().optional(),
2124
- first_appearance: z.string().optional(),
2125
- traits: z.array(z.string()).optional(),
2126
- }).describe("Fields to update. Only supplied keys are changed."),
2127
- },
2128
- async ({ character_id, fields }) => {
2129
- if (!SYNC_DIR_WRITABLE) {
2130
- return errorResponse("READ_ONLY", "Cannot update character: sync dir is read-only.");
2131
- }
2132
- const char = db.prepare(`SELECT file_path FROM characters WHERE character_id = ?`).get(character_id);
2133
- if (!char) {
2134
- return errorResponse("NOT_FOUND", `Character '${character_id}' not found.`);
2135
- }
2136
- try {
2137
- const { meta } = readMeta(char.file_path, SYNC_DIR, { writable: true });
2138
- const updated = { ...meta, ...fields };
2139
- writeMeta(char.file_path, updated);
2140
-
2141
- // Update DB directly
2142
- db.prepare(`
2143
- UPDATE characters SET name = ?, role = ?, arc_summary = ?, first_appearance = ?
2144
- WHERE character_id = ?
2145
- `).run(
2146
- updated.name ?? meta.name, updated.role ?? null,
2147
- updated.arc_summary ?? null, updated.first_appearance ?? null,
2148
- character_id
2149
- );
2150
- if (fields.traits) {
2151
- db.prepare(`DELETE FROM character_traits WHERE character_id = ?`).run(character_id);
2152
- for (const t of fields.traits) {
2153
- db.prepare(`INSERT OR IGNORE INTO character_traits (character_id, trait) VALUES (?, ?)`).run(character_id, t);
2154
- }
2155
- }
2156
-
2157
- return { content: [{ type: "text", text: `Updated character sheet for '${character_id}'.` }] };
2158
- } catch (err) {
2159
- if (err.code === "ENOENT") {
2160
- return errorResponse("STALE_PATH", `Character file for '${character_id}' not found at indexed path — the file may have moved. Run sync() to refresh.`, { indexed_path: char.file_path });
2161
- }
2162
- return errorResponse("IO_ERROR", `Failed to write character metadata for '${character_id}': ${err.message}`);
2163
- }
2164
- }
2165
- );
2166
-
2167
- // ---- update_place_sheet --------------------------------------------------
2168
- s.tool(
2169
- "update_place_sheet",
2170
- "Update structured metadata fields for a place (name, associated_characters, tags). Writes to the .meta.yaml sidecar — never modifies the prose notes file. Changes are immediately reflected in the index. Only available when the sync dir is writable.",
2171
- {
2172
- place_id: z.string().describe("The place_id to update (e.g. 'place-harbor-district'). Use list_places to find valid IDs."),
2173
- fields: z.object({
2174
- name: z.string().optional(),
2175
- associated_characters: z.array(z.string()).optional(),
2176
- tags: z.array(z.string()).optional(),
2177
- }).describe("Fields to update. Only supplied keys are changed."),
2178
- },
2179
- async ({ place_id, fields }) => {
2180
- if (!SYNC_DIR_WRITABLE) {
2181
- return errorResponse("READ_ONLY", "Cannot update place: sync dir is read-only.");
2182
- }
2183
- const place = db.prepare(`SELECT file_path FROM places WHERE place_id = ?`).get(place_id);
2184
- if (!place) {
2185
- return errorResponse("NOT_FOUND", `Place '${place_id}' not found.`);
2186
- }
2187
- try {
2188
- const { meta } = readMeta(place.file_path, SYNC_DIR, { writable: true });
2189
- const updated = { ...meta, ...fields };
2190
- writeMeta(place.file_path, updated);
2191
-
2192
- // Update DB directly
2193
- db.prepare(`UPDATE places SET name = ? WHERE place_id = ?`)
2194
- .run(updated.name ?? meta.name ?? place_id, place_id);
2195
-
2196
- return { content: [{ type: "text", text: `Updated place sheet for '${place_id}'.` }] };
2197
- } catch (err) {
2198
- if (err.code === "ENOENT") {
2199
- return errorResponse("STALE_PATH", `Place file for '${place_id}' not found at indexed path — the file may have moved. Run sync() to refresh.`, { indexed_path: place.file_path });
2200
- }
2201
- return errorResponse("IO_ERROR", `Failed to write place metadata for '${place_id}': ${err.message}`);
2202
- }
2203
- }
2204
- );
2205
-
2206
- // ---- flag_scene ----------------------------------------------------------
2207
- s.tool(
2208
- "flag_scene",
2209
- "Attach a continuity or review note to a scene. Flags are appended to the sidecar file and accumulate over time — they are never overwritten. Use this to record continuity problems, revision notes, or questions you want to revisit.",
2210
- {
2211
- scene_id: z.string().describe("The scene_id to flag (e.g. 'sc-012-open-to-anyone')."),
2212
- project_id: z.string().describe("Project the scene belongs to (e.g. 'the-lamb')."),
2213
- note: z.string().describe("The flag note (e.g. 'Victor knows Mira\u2019s name here, but they haven\u2019t been introduced yet \u2014 contradicts sc-006')."),
2214
- },
2215
- async ({ scene_id, project_id, note }) => {
2216
- if (!SYNC_DIR_WRITABLE) {
2217
- return errorResponse("READ_ONLY", "Cannot flag scene: sync dir is read-only.");
2218
- }
2219
- const scene = db.prepare(`SELECT file_path FROM scenes WHERE scene_id = ? AND project_id = ?`)
2220
- .get(scene_id, project_id);
2221
- if (!scene) {
2222
- return errorResponse("NOT_FOUND", `Scene '${scene_id}' not found in project '${project_id}'.`);
2223
- }
2224
- try {
2225
- const { meta } = readMeta(scene.file_path, SYNC_DIR, { writable: true });
2226
- const flags = meta.flags ?? [];
2227
- flags.push({ note, flagged_at: new Date().toISOString() });
2228
- writeMeta(scene.file_path, { ...meta, flags });
2229
- return { content: [{ type: "text", text: `Flagged scene '${scene_id}': ${note}` }] };
2230
- } catch (err) {
2231
- if (err.code === "ENOENT") {
2232
- return errorResponse("STALE_PATH", `Prose file for scene '${scene_id}' not found at indexed path — the file may have moved. Run sync() to refresh.`, { indexed_path: scene.file_path });
2233
- }
2234
- return errorResponse("IO_ERROR", `Failed to flag scene '${scene_id}': ${err.message}`);
2235
- }
2236
- }
2237
- );
2238
-
2239
1720
  // ---- PHASE 3: Prose Editing (git-backed) --------------------------------
2240
1721
 
2241
1722
  // ---- propose_edit --------------------------------------------------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanna84/mcp-writing",
3
- "version": "2.9.4",
3
+ "version": "2.9.6",
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",
@@ -0,0 +1,344 @@
1
+ import { z } from "zod";
2
+ import fs from "node:fs";
3
+ import matter from "gray-matter";
4
+ import { readMeta, writeMeta, indexSceneFile, normalizeSceneMetaForPath } from "../sync.js";
5
+ import { validateProjectId, validateUniverseId } from "../importer.js";
6
+
7
+ export function registerMetadataTools(s, {
8
+ db,
9
+ SYNC_DIR,
10
+ SYNC_DIR_WRITABLE,
11
+ errorResponse,
12
+ jsonResponse,
13
+ createCanonicalWorldEntity,
14
+ }) {
15
+ // ---- create_character_sheet ---------------------------------------------
16
+ s.tool(
17
+ "create_character_sheet",
18
+ "Create or reuse a canonical character sheet folder with sheet.md and sheet.meta.yaml so the character can be indexed immediately. If the folder already exists, missing canonical files are backfilled and the existing sheet is preserved.",
19
+ {
20
+ name: z.string().describe("Display name of the character (e.g. 'Mira Nystrom')."),
21
+ project_id: z.string().optional().describe("Project scope for a book-local character (e.g. 'universe-1/book-1-the-lamb' or 'test-novel')."),
22
+ universe_id: z.string().optional().describe("Universe scope for a cross-book shared character (e.g. 'universe-1')."),
23
+ notes: z.string().optional().describe("Optional starter prose content for sheet.md."),
24
+ fields: z.object({
25
+ role: z.string().optional(),
26
+ arc_summary: z.string().optional(),
27
+ first_appearance: z.string().optional(),
28
+ traits: z.array(z.string()).optional(),
29
+ }).optional().describe("Optional starter metadata fields for the character sidecar."),
30
+ },
31
+ async ({ name, project_id, universe_id, notes, fields }) => {
32
+ if (!SYNC_DIR_WRITABLE) {
33
+ return errorResponse("READ_ONLY", "Cannot create character sheet: sync dir is read-only.");
34
+ }
35
+ const hasProjectId = project_id !== undefined;
36
+ const hasUniverseId = universe_id !== undefined;
37
+ if ((hasProjectId && hasUniverseId) || (!hasProjectId && !hasUniverseId)) {
38
+ return errorResponse("VALIDATION_ERROR", "Provide exactly one of project_id or universe_id.");
39
+ }
40
+ if (hasProjectId) {
41
+ const check = validateProjectId(project_id);
42
+ if (!check.ok) return errorResponse("INVALID_PROJECT_ID", check.reason, { project_id });
43
+ }
44
+ if (hasUniverseId) {
45
+ const check = validateUniverseId(universe_id);
46
+ if (!check.ok) return errorResponse("INVALID_UNIVERSE_ID", check.reason, { universe_id });
47
+ }
48
+
49
+ try {
50
+ const result = createCanonicalWorldEntity({
51
+ kind: "character",
52
+ name,
53
+ notes,
54
+ projectId: project_id,
55
+ universeId: universe_id,
56
+ meta: fields ?? {},
57
+ });
58
+
59
+ return jsonResponse({ ok: true, action: result.created ? "created" : "exists", kind: "character", ...result });
60
+ } catch (err) {
61
+ return errorResponse("IO_ERROR", `Failed to create character sheet: ${err.message}`);
62
+ }
63
+ }
64
+ );
65
+
66
+ // ---- create_place_sheet -------------------------------------------------
67
+ s.tool(
68
+ "create_place_sheet",
69
+ "Create or reuse a canonical place sheet folder with sheet.md and sheet.meta.yaml so the place can be indexed immediately. If the folder already exists, missing canonical files are backfilled and the existing sheet is preserved.",
70
+ {
71
+ name: z.string().describe("Display name of the place (e.g. 'University Hospital')."),
72
+ project_id: z.string().optional().describe("Project scope for a book-local place (e.g. 'universe-1/book-1-the-lamb' or 'test-novel')."),
73
+ universe_id: z.string().optional().describe("Universe scope for a cross-book shared place (e.g. 'universe-1')."),
74
+ notes: z.string().optional().describe("Optional starter prose content for sheet.md."),
75
+ fields: z.object({
76
+ associated_characters: z.array(z.string()).optional(),
77
+ tags: z.array(z.string()).optional(),
78
+ }).optional().describe("Optional starter metadata fields for the place sidecar."),
79
+ },
80
+ async ({ name, project_id, universe_id, notes, fields }) => {
81
+ if (!SYNC_DIR_WRITABLE) {
82
+ return errorResponse("READ_ONLY", "Cannot create place sheet: sync dir is read-only.");
83
+ }
84
+ const hasProjectId = project_id !== undefined;
85
+ const hasUniverseId = universe_id !== undefined;
86
+ if ((hasProjectId && hasUniverseId) || (!hasProjectId && !hasUniverseId)) {
87
+ return errorResponse("VALIDATION_ERROR", "Provide exactly one of project_id or universe_id.");
88
+ }
89
+ if (hasProjectId) {
90
+ const check = validateProjectId(project_id);
91
+ if (!check.ok) return errorResponse("INVALID_PROJECT_ID", check.reason, { project_id });
92
+ }
93
+ if (hasUniverseId) {
94
+ const check = validateUniverseId(universe_id);
95
+ if (!check.ok) return errorResponse("INVALID_UNIVERSE_ID", check.reason, { universe_id });
96
+ }
97
+
98
+ try {
99
+ const result = createCanonicalWorldEntity({
100
+ kind: "place",
101
+ name,
102
+ notes,
103
+ projectId: project_id,
104
+ universeId: universe_id,
105
+ meta: fields ?? {},
106
+ });
107
+
108
+ return jsonResponse({ ok: true, action: result.created ? "created" : "exists", kind: "place", ...result });
109
+ } catch (err) {
110
+ return errorResponse("IO_ERROR", `Failed to create place sheet: ${err.message}`);
111
+ }
112
+ }
113
+ );
114
+
115
+ // ---- upsert_thread_link --------------------------------------------------
116
+ s.tool(
117
+ "upsert_thread_link",
118
+ "Create or update a thread and link it to a scene. Idempotent: if the link already exists, updates its beat. Only available when the sync dir is writable.",
119
+ {
120
+ project_id: z.string().describe("Project the thread belongs to (e.g. 'the-lamb')."),
121
+ thread_id: z.string().describe("Thread ID (e.g. 'thread-reconciliation')."),
122
+ thread_name: z.string().describe("Thread display name."),
123
+ scene_id: z.string().describe("Scene to link to the thread (e.g. 'sc-011-sebastian')."),
124
+ beat: z.string().optional().describe("Optional thread-specific beat label for this scene."),
125
+ status: z.string().optional().describe("Thread status (e.g. 'active', 'resolved'). Defaults to 'active'."),
126
+ },
127
+ async ({ project_id, thread_id, thread_name, scene_id, beat, status }) => {
128
+ if (!SYNC_DIR_WRITABLE) {
129
+ return errorResponse("READ_ONLY", "Cannot write thread links: sync dir is read-only.");
130
+ }
131
+
132
+ const existingThread = db.prepare(`SELECT thread_id, project_id FROM threads WHERE thread_id = ?`).get(thread_id);
133
+ if (existingThread && existingThread.project_id !== project_id) {
134
+ return errorResponse(
135
+ "CONFLICT",
136
+ `Thread '${thread_id}' already exists in project '${existingThread.project_id}', cannot reuse it for project '${project_id}'.`
137
+ );
138
+ }
139
+
140
+ const scene = db.prepare(`SELECT scene_id FROM scenes WHERE scene_id = ? AND project_id = ?`).get(scene_id, project_id);
141
+ if (!scene) {
142
+ return errorResponse("NOT_FOUND", `Scene '${scene_id}' not found in project '${project_id}'.`);
143
+ }
144
+
145
+ db.prepare(`
146
+ INSERT INTO threads (thread_id, project_id, name, status)
147
+ VALUES (?, ?, ?, ?)
148
+ ON CONFLICT (thread_id) DO UPDATE SET
149
+ name = excluded.name,
150
+ status = excluded.status
151
+ `).run(thread_id, project_id, thread_name, status ?? "active");
152
+
153
+ db.prepare(`
154
+ INSERT INTO scene_threads (scene_id, thread_id, beat)
155
+ VALUES (?, ?, ?)
156
+ ON CONFLICT (scene_id, thread_id) DO UPDATE SET
157
+ beat = excluded.beat
158
+ `).run(scene_id, thread_id, beat ?? null);
159
+
160
+ const thread = db.prepare(`SELECT * FROM threads WHERE thread_id = ?`).get(thread_id);
161
+ const link = db.prepare(`SELECT scene_id, thread_id, beat FROM scene_threads WHERE scene_id = ? AND thread_id = ?`)
162
+ .get(scene_id, thread_id);
163
+
164
+ return jsonResponse({
165
+ ok: true,
166
+ action: "upserted",
167
+ thread,
168
+ link,
169
+ });
170
+ }
171
+ );
172
+
173
+ // ---- update_scene_metadata -----------------------------------------------
174
+ s.tool(
175
+ "update_scene_metadata",
176
+ "Update one or more metadata fields for a scene. Writes to the .meta.yaml sidecar — never modifies prose. Changes are immediately reflected in the index. Only available when the sync dir is writable.",
177
+ {
178
+ scene_id: z.string().describe("The scene_id to update (e.g. 'sc-011-sebastian')."),
179
+ project_id: z.string().describe("Project the scene belongs to (e.g. 'the-lamb')."),
180
+ fields: z.object({
181
+ title: z.string().optional(),
182
+ logline: z.string().optional(),
183
+ status: z.string().optional().describe("Workflow status (e.g. 'draft', 'revision', 'complete'). Free text — no fixed vocabulary."),
184
+ save_the_cat_beat: z.string().optional(),
185
+ pov: z.string().optional(),
186
+ part: z.number().int().optional(),
187
+ chapter: z.number().int().optional(),
188
+ timeline_position: z.number().int().optional(),
189
+ story_time: z.string().optional(),
190
+ tags: z.array(z.string()).optional(),
191
+ characters: z.array(z.string()).optional(),
192
+ places: z.array(z.string()).optional(),
193
+ }).describe("Fields to update. Only supplied keys are changed."),
194
+ },
195
+ async ({ scene_id, project_id, fields }) => {
196
+ if (!SYNC_DIR_WRITABLE) {
197
+ return errorResponse("READ_ONLY", "Cannot update metadata: sync dir is read-only.");
198
+ }
199
+ const scene = db.prepare(`SELECT file_path FROM scenes WHERE scene_id = ? AND project_id = ?`)
200
+ .get(scene_id, project_id);
201
+ if (!scene) {
202
+ return errorResponse("NOT_FOUND", `Scene '${scene_id}' not found in project '${project_id}'.`);
203
+ }
204
+ try {
205
+ const { meta } = readMeta(scene.file_path, SYNC_DIR, { writable: true });
206
+ const updated = normalizeSceneMetaForPath(SYNC_DIR, scene.file_path, { ...meta, ...fields }).meta;
207
+ writeMeta(scene.file_path, updated);
208
+
209
+ const { content: prose } = matter(fs.readFileSync(scene.file_path, "utf8"));
210
+ indexSceneFile(db, SYNC_DIR, scene.file_path, updated, prose);
211
+
212
+ return { content: [{ type: "text", text: `Updated metadata for scene '${scene_id}'.` }] };
213
+ } catch (err) {
214
+ if (err.code === "ENOENT") {
215
+ return errorResponse("STALE_PATH", `Prose file for scene '${scene_id}' not found at indexed path — the file may have moved. Run sync() to refresh.`, { indexed_path: scene.file_path });
216
+ }
217
+ return errorResponse("IO_ERROR", `Failed to write metadata for scene '${scene_id}': ${err.message}`);
218
+ }
219
+ }
220
+ );
221
+
222
+ // ---- update_character_sheet ----------------------------------------------
223
+ s.tool(
224
+ "update_character_sheet",
225
+ "Update structured metadata fields for a character (role, arc_summary, traits, etc). Writes to the .meta.yaml sidecar — never modifies the prose notes file. Changes are immediately reflected in the index. Only available when the sync dir is writable.",
226
+ {
227
+ character_id: z.string().describe("The character_id to update (e.g. 'char-mira-nystrom'). Use list_characters to find valid IDs."),
228
+ fields: z.object({
229
+ name: z.string().optional(),
230
+ role: z.string().optional(),
231
+ arc_summary: z.string().optional(),
232
+ first_appearance: z.string().optional(),
233
+ traits: z.array(z.string()).optional(),
234
+ }).describe("Fields to update. Only supplied keys are changed."),
235
+ },
236
+ async ({ character_id, fields }) => {
237
+ if (!SYNC_DIR_WRITABLE) {
238
+ return errorResponse("READ_ONLY", "Cannot update character: sync dir is read-only.");
239
+ }
240
+ const char = db.prepare(`SELECT file_path FROM characters WHERE character_id = ?`).get(character_id);
241
+ if (!char) {
242
+ return errorResponse("NOT_FOUND", `Character '${character_id}' not found.`);
243
+ }
244
+ try {
245
+ const { meta } = readMeta(char.file_path, SYNC_DIR, { writable: true });
246
+ const updated = { ...meta, ...fields };
247
+ writeMeta(char.file_path, updated);
248
+
249
+ db.prepare(`
250
+ UPDATE characters SET name = ?, role = ?, arc_summary = ?, first_appearance = ?
251
+ WHERE character_id = ?
252
+ `).run(
253
+ updated.name ?? meta.name, updated.role ?? null,
254
+ updated.arc_summary ?? null, updated.first_appearance ?? null,
255
+ character_id
256
+ );
257
+ if (fields.traits) {
258
+ db.prepare(`DELETE FROM character_traits WHERE character_id = ?`).run(character_id);
259
+ for (const t of fields.traits) {
260
+ db.prepare(`INSERT OR IGNORE INTO character_traits (character_id, trait) VALUES (?, ?)`).run(character_id, t);
261
+ }
262
+ }
263
+
264
+ return { content: [{ type: "text", text: `Updated character sheet for '${character_id}'.` }] };
265
+ } catch (err) {
266
+ if (err.code === "ENOENT") {
267
+ return errorResponse("STALE_PATH", `Character file for '${character_id}' not found at indexed path — the file may have moved. Run sync() to refresh.`, { indexed_path: char.file_path });
268
+ }
269
+ return errorResponse("IO_ERROR", `Failed to write character metadata for '${character_id}': ${err.message}`);
270
+ }
271
+ }
272
+ );
273
+
274
+ // ---- update_place_sheet --------------------------------------------------
275
+ s.tool(
276
+ "update_place_sheet",
277
+ "Update structured metadata fields for a place (name, associated_characters, tags). Writes to the .meta.yaml sidecar — never modifies the prose notes file. Changes are immediately reflected in the index. Only available when the sync dir is writable.",
278
+ {
279
+ place_id: z.string().describe("The place_id to update (e.g. 'place-harbor-district'). Use list_places to find valid IDs."),
280
+ fields: z.object({
281
+ name: z.string().optional(),
282
+ associated_characters: z.array(z.string()).optional(),
283
+ tags: z.array(z.string()).optional(),
284
+ }).describe("Fields to update. Only supplied keys are changed."),
285
+ },
286
+ async ({ place_id, fields }) => {
287
+ if (!SYNC_DIR_WRITABLE) {
288
+ return errorResponse("READ_ONLY", "Cannot update place: sync dir is read-only.");
289
+ }
290
+ const place = db.prepare(`SELECT file_path FROM places WHERE place_id = ?`).get(place_id);
291
+ if (!place) {
292
+ return errorResponse("NOT_FOUND", `Place '${place_id}' not found.`);
293
+ }
294
+ try {
295
+ const { meta } = readMeta(place.file_path, SYNC_DIR, { writable: true });
296
+ const updated = { ...meta, ...fields };
297
+ writeMeta(place.file_path, updated);
298
+
299
+ db.prepare(`UPDATE places SET name = ? WHERE place_id = ?`)
300
+ .run(updated.name ?? meta.name ?? place_id, place_id);
301
+
302
+ return { content: [{ type: "text", text: `Updated place sheet for '${place_id}'.` }] };
303
+ } catch (err) {
304
+ if (err.code === "ENOENT") {
305
+ return errorResponse("STALE_PATH", `Place file for '${place_id}' not found at indexed path — the file may have moved. Run sync() to refresh.`, { indexed_path: place.file_path });
306
+ }
307
+ return errorResponse("IO_ERROR", `Failed to write place metadata for '${place_id}': ${err.message}`);
308
+ }
309
+ }
310
+ );
311
+
312
+ // ---- flag_scene ----------------------------------------------------------
313
+ s.tool(
314
+ "flag_scene",
315
+ "Attach a continuity or review note to a scene. Flags are appended to the sidecar file and accumulate over time — they are never overwritten. Use this to record continuity problems, revision notes, or questions you want to revisit.",
316
+ {
317
+ scene_id: z.string().describe("The scene_id to flag (e.g. 'sc-012-open-to-anyone')."),
318
+ project_id: z.string().describe("Project the scene belongs to (e.g. 'the-lamb')."),
319
+ note: z.string().describe("The flag note (e.g. 'Victor knows Mira’s name here, but they haven’t been introduced yet — contradicts sc-006')."),
320
+ },
321
+ async ({ scene_id, project_id, note }) => {
322
+ if (!SYNC_DIR_WRITABLE) {
323
+ return errorResponse("READ_ONLY", "Cannot flag scene: sync dir is read-only.");
324
+ }
325
+ const scene = db.prepare(`SELECT file_path FROM scenes WHERE scene_id = ? AND project_id = ?`)
326
+ .get(scene_id, project_id);
327
+ if (!scene) {
328
+ return errorResponse("NOT_FOUND", `Scene '${scene_id}' not found in project '${project_id}'.`);
329
+ }
330
+ try {
331
+ const { meta } = readMeta(scene.file_path, SYNC_DIR, { writable: true });
332
+ const flags = meta.flags ?? [];
333
+ flags.push({ note, flagged_at: new Date().toISOString() });
334
+ writeMeta(scene.file_path, { ...meta, flags });
335
+ return { content: [{ type: "text", text: `Flagged scene '${scene_id}': ${note}` }] };
336
+ } catch (err) {
337
+ if (err.code === "ENOENT") {
338
+ return errorResponse("STALE_PATH", `Prose file for scene '${scene_id}' not found at indexed path — the file may have moved. Run sync() to refresh.`, { indexed_path: scene.file_path });
339
+ }
340
+ return errorResponse("IO_ERROR", `Failed to flag scene '${scene_id}': ${err.message}`);
341
+ }
342
+ }
343
+ );
344
+ }
@@ -0,0 +1,207 @@
1
+ import { z } from "zod";
2
+ import path from "node:path";
3
+ import {
4
+ REVIEW_BUNDLE_PROFILES,
5
+ REVIEW_BUNDLE_STRICTNESS,
6
+ ReviewBundlePlanError,
7
+ buildReviewBundlePlan,
8
+ createReviewBundleArtifacts,
9
+ } from "../review-bundles.js";
10
+ import { validateProjectId } from "../importer.js";
11
+ import { getHeadCommitHash } from "../git.js";
12
+
13
+ export function registerReviewBundleTools(s, {
14
+ db,
15
+ SYNC_DIR,
16
+ SYNC_DIR_ABS,
17
+ GIT_ENABLED,
18
+ errorResponse,
19
+ jsonResponse,
20
+ resolveOutputDirWithinSync,
21
+ }) {
22
+ // ---- preview_review_bundle ----------------------------------------------
23
+ s.tool(
24
+ "preview_review_bundle",
25
+ "Dry-run planning tool for review bundles. Resolves scene scope, deterministic ordering, warnings, and planned output filenames without writing files. Rendering options are accepted for API consistency and reflected in resolved_scope.options, but do not change planning output.",
26
+ {
27
+ project_id: z.string().describe("Project ID to scope the review bundle (e.g. 'test-novel')."),
28
+ profile: z.enum(REVIEW_BUNDLE_PROFILES).describe("Bundle profile: outline_discussion, editor_detailed, or beta_reader_personalized."),
29
+ part: z.number().int().optional().describe("Optional part filter."),
30
+ chapter: z.number().int().optional().describe("Optional chapter filter."),
31
+ tag: z.string().optional().describe("Optional tag filter (exact match)."),
32
+ scene_ids: z.array(z.string()).optional().describe("Optional explicit scene_id allowlist. Intersects with other filters."),
33
+ strictness: z.enum(REVIEW_BUNDLE_STRICTNESS).optional().describe("Strictness mode: warn (default) or fail."),
34
+ include_scene_ids: z.boolean().optional().describe("Rendering option (default true). Echoed in resolved_scope.options for downstream rendering; does not change planning results."),
35
+ include_metadata_sidebar: z.boolean().optional().describe("Rendering option (default false). Echoed in resolved_scope.options for downstream rendering; does not change planning results."),
36
+ include_paragraph_anchors: z.boolean().optional().describe("Rendering option (default false). Echoed in resolved_scope.options for downstream rendering; does not change planning results."),
37
+ recipient_name: z.string().optional().describe("Optional recipient display name for beta_reader_personalized profile."),
38
+ bundle_name: z.string().optional().describe("Optional output bundle base name override (slugified in planned outputs)."),
39
+ format: z.enum(["pdf", "markdown", "both"]).optional().describe("Planned output format: pdf (default), markdown, or both. Affects planned_outputs filenames only; preview_review_bundle does not render artifacts."),
40
+ },
41
+ async ({
42
+ project_id,
43
+ profile,
44
+ part,
45
+ chapter,
46
+ tag,
47
+ scene_ids,
48
+ strictness = "warn",
49
+ include_scene_ids = true,
50
+ include_metadata_sidebar = false,
51
+ include_paragraph_anchors = false,
52
+ recipient_name,
53
+ bundle_name,
54
+ format = "pdf",
55
+ }) => {
56
+ const projectIdCheck = validateProjectId(project_id);
57
+ if (!projectIdCheck.ok) {
58
+ return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
59
+ }
60
+
61
+ try {
62
+ const plan = buildReviewBundlePlan(db, {
63
+ project_id,
64
+ profile,
65
+ part,
66
+ chapter,
67
+ tag,
68
+ scene_ids,
69
+ strictness,
70
+ include_scene_ids,
71
+ include_metadata_sidebar,
72
+ include_paragraph_anchors,
73
+ recipient_name,
74
+ bundle_name,
75
+ format,
76
+ });
77
+ return jsonResponse(plan);
78
+ } catch (error) {
79
+ if (error instanceof ReviewBundlePlanError) {
80
+ return errorResponse(error.code, error.message, error.details);
81
+ }
82
+ return errorResponse(
83
+ "PREVIEW_FAILED",
84
+ error instanceof Error ? error.message : "Failed to generate review bundle preview."
85
+ );
86
+ }
87
+ }
88
+ );
89
+
90
+ // ---- create_review_bundle -----------------------------------------------
91
+ s.tool(
92
+ "create_review_bundle",
93
+ "Generate review bundle artifacts (PDF/markdown) from planned scene scope. Writes files only under output_dir and returns manifest/provenance details.",
94
+ {
95
+ project_id: z.string().describe("Project ID to scope the review bundle (e.g. 'test-novel')."),
96
+ profile: z.enum(REVIEW_BUNDLE_PROFILES).describe("Bundle profile: outline_discussion, editor_detailed, or beta_reader_personalized."),
97
+ output_dir: z.string().describe("Directory path to write bundle artifacts into."),
98
+ part: z.number().int().optional().describe("Optional part filter."),
99
+ chapter: z.number().int().optional().describe("Optional chapter filter."),
100
+ tag: z.string().optional().describe("Optional tag filter (exact match)."),
101
+ scene_ids: z.array(z.string()).optional().describe("Optional explicit scene_id allowlist. Intersects with other filters."),
102
+ strictness: z.enum(REVIEW_BUNDLE_STRICTNESS).optional().describe("Strictness mode: warn (default) or fail."),
103
+ include_scene_ids: z.boolean().optional().describe("Include scene IDs in headings (default true). Applies to both PDF and markdown."),
104
+ include_metadata_sidebar: z.boolean().optional().describe("Include metadata sidebar in markdown output (default false). Markdown only — no effect on PDF."),
105
+ include_paragraph_anchors: z.boolean().optional().describe("Include paragraph anchors in markdown output (default false). Markdown only — no effect on PDF."),
106
+ recipient_name: z.string().optional().describe("Optional recipient display name for beta_reader_personalized profile."),
107
+ bundle_name: z.string().optional().describe("Optional output bundle base name override (slugified in filenames)."),
108
+ source_commit: z.string().optional().describe("Optional explicit source commit for provenance. Defaults to current HEAD when available."),
109
+ format: z.enum(["pdf", "markdown", "both"]).optional().describe("Output format: pdf (default), markdown, or both."),
110
+ },
111
+ async ({
112
+ project_id,
113
+ profile,
114
+ output_dir,
115
+ part,
116
+ chapter,
117
+ tag,
118
+ scene_ids,
119
+ strictness = "warn",
120
+ include_scene_ids = true,
121
+ include_metadata_sidebar = false,
122
+ include_paragraph_anchors = false,
123
+ recipient_name,
124
+ bundle_name,
125
+ source_commit,
126
+ format = "pdf",
127
+ }) => {
128
+ const projectIdCheck = validateProjectId(project_id);
129
+ if (!projectIdCheck.ok) {
130
+ return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
131
+ }
132
+
133
+ try {
134
+ const { resolvedOutputDir, relativeToSyncDir } = resolveOutputDirWithinSync(output_dir);
135
+ const outputDirSegments = relativeToSyncDir
136
+ .split(path.sep)
137
+ .filter(Boolean)
138
+ .map(segment => segment.toLowerCase());
139
+ if (outputDirSegments.includes("scenes")) {
140
+ return errorResponse(
141
+ "INVALID_OUTPUT_DIR",
142
+ "output_dir cannot be inside a scenes directory. Choose a dedicated export folder under WRITING_SYNC_DIR.",
143
+ { output_dir: resolvedOutputDir }
144
+ );
145
+ }
146
+
147
+ const plan = buildReviewBundlePlan(db, {
148
+ project_id,
149
+ profile,
150
+ part,
151
+ chapter,
152
+ tag,
153
+ scene_ids,
154
+ strictness,
155
+ include_scene_ids,
156
+ include_metadata_sidebar,
157
+ include_paragraph_anchors,
158
+ recipient_name,
159
+ bundle_name,
160
+ format,
161
+ });
162
+
163
+ if (!plan.strictness_result.can_proceed) {
164
+ return errorResponse(
165
+ "STRICTNESS_BLOCKED",
166
+ "Bundle generation blocked by strictness policy.",
167
+ { strictness_result: plan.strictness_result, warning_summary: plan.warning_summary }
168
+ );
169
+ }
170
+
171
+ const provenanceCommit = source_commit ?? (GIT_ENABLED ? getHeadCommitHash(SYNC_DIR) : null);
172
+ const artifacts = await createReviewBundleArtifacts(db, {
173
+ plan,
174
+ output_dir: resolvedOutputDir,
175
+ source_commit: provenanceCommit,
176
+ syncDir: SYNC_DIR_ABS,
177
+ });
178
+
179
+ return jsonResponse({
180
+ ok: true,
181
+ bundle_id: artifacts.bundle_id,
182
+ output_paths: artifacts.output_paths,
183
+ summary: {
184
+ scene_count: plan.summary.scene_count,
185
+ profile: plan.profile,
186
+ applied_filters: plan.resolved_scope.filters,
187
+ },
188
+ warnings: plan.warnings,
189
+ warning_summary: plan.warning_summary,
190
+ provenance: {
191
+ source_commit: provenanceCommit,
192
+ generated_at: artifacts.generated_at,
193
+ project_id: plan.resolved_scope.project_id,
194
+ },
195
+ });
196
+ } catch (error) {
197
+ if (error instanceof ReviewBundlePlanError) {
198
+ return errorResponse(error.code, error.message, error.details);
199
+ }
200
+ return errorResponse(
201
+ "CREATE_BUNDLE_FAILED",
202
+ error instanceof Error ? error.message : "Failed to create review bundle artifacts."
203
+ );
204
+ }
205
+ }
206
+ );
207
+ }