@hanna84/mcp-writing 1.1.1 → 1.3.7

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
@@ -1,5 +1,73 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.3.7](https://github.com/hannasdev/mcp-writing/compare/v1.3.6...v1.3.7) (2026-04-18)
4
+
5
+
6
+ ### Miscellaneous Chores
7
+
8
+ * **ci:** remove npm whoami diagnostic step ([65435e9](https://github.com/hannasdev/mcp-writing/commit/65435e9c8cfa829ceb6937904250c46afc45b54c))
9
+
10
+ ## [1.3.6](https://github.com/hannasdev/mcp-writing/compare/v1.3.5...v1.3.6) (2026-04-18)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * **ci:** restore npm trusted publishing auth config ([f043368](https://github.com/hannasdev/mcp-writing/commit/f0433681e984e34d1c38725f2d85dc3d14031626))
16
+
17
+ ## [1.3.5](https://github.com/hannasdev/mcp-writing/compare/v1.3.4...v1.3.5) (2026-04-18)
18
+
19
+
20
+ ### Bug Fixes
21
+
22
+ * **ci:** force OIDC-only npm publish auth ([c9f1216](https://github.com/hannasdev/mcp-writing/commit/c9f1216f2d3aa49fcc2aad5e6c4032962ad843d1))
23
+
24
+ ## [1.3.4](https://github.com/hannasdev/mcp-writing/compare/v1.3.3...v1.3.4) (2026-04-18)
25
+
26
+
27
+ ### Bug Fixes
28
+
29
+ * **ci:** use tag trigger for npm publish (matches n8n pattern) ([#18](https://github.com/hannasdev/mcp-writing/issues/18)) ([a3c5992](https://github.com/hannasdev/mcp-writing/commit/a3c5992e8cb560aa79b554389d41457fcf3c0cfd))
30
+
31
+ ## [1.3.3](https://github.com/hannasdev/mcp-writing/compare/v1.3.2...v1.3.3) (2026-04-18)
32
+
33
+
34
+ ### Bug Fixes
35
+
36
+ * **ci:** use tag trigger for npm publish (matches n8n pattern) ([6841de7](https://github.com/hannasdev/mcp-writing/commit/6841de78a90c3b4c121761d28a09b02f9d360a96))
37
+
38
+ ## [1.3.2](https://github.com/hannasdev/mcp-writing/compare/v1.3.1...v1.3.2) (2026-04-18)
39
+
40
+
41
+ ### Bug Fixes
42
+
43
+ * **ci:** publish from main release commits for npm trusted publishing ([135ab25](https://github.com/hannasdev/mcp-writing/commit/135ab254fb91ee6427a79cdecdc5b9bf55cfb4d1))
44
+
45
+ ## [1.3.1](https://github.com/hannasdev/mcp-writing/compare/v1.3.0...v1.3.1) (2026-04-18)
46
+
47
+
48
+ ### Bug Fixes
49
+
50
+ * **ci:** set npm environment for trusted publishing ([b75fb08](https://github.com/hannasdev/mcp-writing/commit/b75fb0897dbbbd9df2d136cbb78a06218f0c0b50))
51
+
52
+
53
+ ### Miscellaneous Chores
54
+
55
+ * generate integration fixtures at runtime and remove test-sync ([#14](https://github.com/hannasdev/mcp-writing/issues/14)) ([0ff5024](https://github.com/hannasdev/mcp-writing/commit/0ff5024f0c3724b099035a6f76fbe4c517870652))
56
+
57
+ ## [1.3.0](https://github.com/hannasdev/mcp-writing/compare/v1.2.0...v1.3.0) (2026-04-17)
58
+
59
+
60
+ ### Features
61
+
62
+ * formalize non-draft content and stable Scrivener imports ([#12](https://github.com/hannasdev/mcp-writing/issues/12)) ([70b4beb](https://github.com/hannasdev/mcp-writing/commit/70b4bebd0d3cd823ffe3a9251f59f9a0c0e5f12f))
63
+
64
+ ## [1.2.0](https://github.com/hannasdev/mcp-writing/compare/v1.1.1...v1.2.0) (2026-04-17)
65
+
66
+
67
+ ### Features
68
+
69
+ * Phase 3 git-backed prose editing, runtime config tool, startup path logging ([f30a7c8](https://github.com/hannasdev/mcp-writing/commit/f30a7c8b5e5aaf070b0b6cfaec38479141c5f60b))
70
+
3
71
  ## [1.1.1](https://github.com/hannasdev/mcp-writing/compare/v1.1.0...v1.1.1) (2026-04-16)
4
72
 
5
73
 
package/README.md CHANGED
@@ -9,16 +9,36 @@ Designed to work with [OpenClaw](https://github.com/openclaw/openclaw) but compa
9
9
  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.
10
10
 
11
11
  **Phase 1:** Read-only analysis. Ask questions about your project.
12
- **Phase 2 (current):** Metadata write-back. Answers stay accurate as the manuscript evolves.
13
- **Phase 3:** AI-assisted prose editing with confirmation and version history.
12
+ **Phase 2:** Metadata write-back. Answers stay accurate as the manuscript evolves.
13
+ **Phase 3 (current):** AI-assisted prose editing with confirmation and version history.
14
+
15
+ ## Who it is for
16
+
17
+ - Novelists and writing teams working on long manuscripts with many scenes, characters, and continuity constraints.
18
+ - AI-assisted editing workflows where you want targeted context retrieval instead of full-manuscript prompting.
19
+ - Projects that need traceable, reversible edits with metadata that stays synchronized as drafts evolve.
20
+
21
+ ## Prerequisites
22
+
23
+ - **Node.js 22.6.0 or later** (required for SQLite support via `--experimental-sqlite` flag)
24
+ - **npm 8.0.0 or later**
25
+ - **Git** (for edit snapshots and version history)
26
+
27
+ Verify your setup:
28
+
29
+ ```sh
30
+ node --version # should be v22.6.0 or later
31
+ npm --version # should be 8.0.0 or later
32
+ git --version # should be installed
33
+ ```
14
34
 
15
35
  ## Quick start with Scrivener
16
36
 
17
- If you write in [Scrivener](https://www.literatureandlatte.com/scrivener), you can seed `mcp-writing` from a Scrivener external-sync export in two steps.
37
+ If you write in [Scrivener](https://www.literatureandlatte.com/scrivener), you can seed `mcp-writing` from a Scrivener external-sync export for scene prose, then curate non-draft content directly into the target folder structure.
18
38
 
19
39
  ### 1. Export from Scrivener
20
40
 
21
- In Scrivener: **File → Sync → With External Folder**. Set the format to **plain text** (`.txt`) and pick an output folder, for example `~/my-novel-txt/`. Your `Draft/` folder and `Notes/` folder will be exported as numbered `.txt` files.
41
+ In Scrivener: **File → Sync → With External Folder**. Set the format to **plain text** (`.txt`) and pick an output folder, for example `~/my-novel-txt/`. `mcp-writing` imports the `Draft/` folder automatically.
22
42
 
23
43
  ### 2. Import into mcp-writing
24
44
 
@@ -27,11 +47,11 @@ node scripts/import.js ~/my-novel-txt /path/to/sync-dir --project my-novel
27
47
  ```
28
48
 
29
49
  The importer:
50
+
30
51
  - Converts `Draft/` files to scene sidecars (`.meta.yaml`) with auto-generated `scene_id`, `title`, `part`, `chapter`, and `save_the_cat_beat` fields derived from the filename/structure.
31
- - Routes `Notes/` files into `world/characters/` or `world/places/` based on section grouping.
32
52
  - Skips beat-marker files (`-Setup-`, `-Catalyst-`, etc.), chapter-intro files, epigraphs, and trashed files.
33
53
 
34
- > **Note:** The importer writes a `group` key to character sidecar files so characters stay organized by their Notes section (e.g. "Main Characters", "Mira's team"). This is preserved through lint and sync.
54
+ Non-draft content is not inferred from `Notes/`. Put it directly into the target sync dir using the `world/` folder conventions described below.
35
55
 
36
56
  ### 3. Start the server
37
57
 
@@ -39,6 +59,14 @@ The importer:
39
59
  WRITING_SYNC_DIR=/path/to/sync-dir DB_PATH=./writing.db npm start
40
60
  ```
41
61
 
62
+ You should see:
63
+
64
+ ```
65
+ Listening on port 3000
66
+ Sync dir: /path/to/sync-dir
67
+ Database: ./writing.db
68
+ ```
69
+
42
70
  Then call the `sync` tool once to index everything.
43
71
 
44
72
  ### 4. Lint your metadata (optional)
@@ -80,13 +108,15 @@ Alternatively, metadata can live in a sidecar file named `<scene-file>.meta.yaml
80
108
 
81
109
  ### Project structure
82
110
 
83
- ```
111
+ ```bash
84
112
  /sync-root/
85
113
  /universes/
86
114
  /my-series/
87
115
  /world/
88
- characters/elena.md
89
- places/harbor-district.md
116
+ characters/elena/sheet.md
117
+ characters/elena/arc.md
118
+ places/harbor-district/sheet.md
119
+ reference/vampire-biology.md
90
120
  /book-1/
91
121
  /part-1/chapter-1/scene-001.md
92
122
  /projects/
@@ -94,11 +124,107 @@ Alternatively, metadata can live in a sidecar file named `<scene-file>.meta.yaml
94
124
  /world/
95
125
  characters/
96
126
  places/
127
+ reference/
97
128
  /part-1/chapter-1/scene-001.md
98
129
  ```
99
130
 
131
+ Character/place folders use one canonical sheet file for entity indexing:
132
+
133
+ - `world/characters/<slug>/sheet.md` or `sheet.txt`
134
+ - `world/places/<slug>/sheet.md` or `sheet.txt`
135
+
136
+ Additional files in the same folder are treated as support notes, not separate entities.
137
+
100
138
  Universe-level characters and places are shared across all books in that universe. Standalone projects are fully isolated.
101
139
 
140
+ ### Scaffolding templates
141
+
142
+ Yes, a template is helpful here. It keeps the canonical metadata fields consistent, lowers the friction of adding a new character or place, and reduces the chance that a file is created in the right folder but missing the fields needed for indexing.
143
+
144
+ Use the scaffold script to create a canonical character or place folder:
145
+
146
+ ```sh
147
+ npm run new:entity -- --sync-dir /path/to/sync-root --kind character --scope universe --universe my-series --name "Mira Nystrom"
148
+ ```
149
+
150
+ ```sh
151
+ npm run new:entity -- --sync-dir /path/to/sync-root --kind place --scope project --project my-series/book-1 --name "University Hospital"
152
+ ```
153
+
154
+ This creates:
155
+
156
+ - `world/characters/<slug>/sheet.md` plus `arc.md` for character arcs
157
+ - `world/places/<slug>/sheet.md` for places
158
+ - `sheet.meta.yaml` with the required entity ID and starter fields
159
+
160
+ Generated Markdown follows one formatting contract so scaffolded files are predictable to edit:
161
+
162
+ - the first line is a top-level title (`# Name`)
163
+ - every heading is followed by a blank line
164
+ - every generated `.md` file ends with a trailing blank line
165
+
166
+ Use `--dry-run` to preview the path without writing files.
167
+
168
+ You can also create canonical sheets directly through the MCP server with `create_character_sheet` and `create_place_sheet`, then move your existing raw notes into the generated folders.
169
+
170
+ Recommended workflow:
171
+
172
+ 1. Scaffold the entity folder and canonical sheet.
173
+ 2. Fill in the metadata fields you care about first.
174
+ 3. For characters, fill in `arc.md` as a separate arc-analysis document.
175
+ 4. Add any other nearby notes like `relationships.md` or `history.md` as needed.
176
+ 5. Run `sync` to index the new entity.
177
+
178
+ ---
179
+
180
+ ## Real-world usage scenarios
181
+
182
+ The tool list is useful as reference. These example workflows show how people actually use `mcp-writing` while drafting and revising.
183
+
184
+ ### 1) Continuity pass before sending chapters to beta readers
185
+
186
+ Goal: catch inconsistencies before sharing pages.
187
+
188
+ 1. Run `sync` after your latest writing session.
189
+ 2. Ask `find_scenes` for scenes involving a specific character or tag (for example, all scenes tagged `injury` or `promise`).
190
+ 3. Use `get_arc` to review that character's ordered progression across the manuscript.
191
+ 4. Load only the suspect scenes with `get_scene_prose`.
192
+ 5. Attach follow-up notes with `flag_scene` where continuity needs a fix.
193
+
194
+ Outcome: you review one narrative thread at a time instead of rereading the entire novel to find contradictions.
195
+
196
+ ### 2) Planning and tracking subplot beats during revisions
197
+
198
+ Goal: make sure subplot threads progress intentionally and resolve on time.
199
+
200
+ 1. Run `list_threads` for the project.
201
+ 2. Use `get_thread_arc` to inspect scene order and beat labels for each thread.
202
+ 3. When a beat is missing, call `upsert_thread_link` to add or update it on the right scene.
203
+ 4. Re-run `get_thread_arc` to confirm pacing and coverage.
204
+
205
+ Outcome: subplot structure stays visible and auditable, which reduces dropped threads in late drafts.
206
+
207
+ ### 3) Tightening scene metadata after heavy prose edits
208
+
209
+ Goal: keep indexes accurate without manually re-tagging everything.
210
+
211
+ 1. After rewriting scenes, call `enrich_scene` to re-derive lightweight metadata from current prose.
212
+ 2. Use `update_scene_metadata` for intentional editorial fields (for example, beat, POV, timeline position, and tags).
213
+ 3. Use `search_metadata` and `find_scenes` to verify scenes are discoverable under the expected filters.
214
+
215
+ Outcome: your AI assistant can reliably find the right scenes without drifting from the manuscript.
216
+
217
+ ### 4) Safe AI-assisted line edits with rollback
218
+
219
+ Goal: let AI propose prose edits without losing control of your draft.
220
+
221
+ 1. Ask the AI to call `propose_edit` for a specific scene.
222
+ 2. Review the staged diff.
223
+ 3. Accept with `commit_edit` or reject with `discard_edit`.
224
+ 4. Use `list_snapshots` (and optional `snapshot_scene`) to inspect or preserve revision history.
225
+
226
+ Outcome: you get AI speed with explicit approval and recoverable history for every applied change.
227
+
102
228
  ---
103
229
 
104
230
  ## Available tools
@@ -109,10 +235,14 @@ Universe-level characters and places are shared across all books in that univers
109
235
  | `find_scenes` | Filter scenes by character, beat, tag, part, chapter, or POV |
110
236
  | `get_scene_prose` | Load the full prose for a specific scene |
111
237
  | `get_chapter_prose` | Load all prose for a chapter |
238
+ | `get_runtime_config` | Show the active sync dir, DB path, and runtime capabilities |
112
239
  | `get_arc` | Ordered scene metadata for all scenes involving a character |
113
240
  | `list_characters` | All characters, optionally filtered by project or universe |
114
- | `get_character_sheet` | Full character metadata, traits, and notes |
241
+ | `get_character_sheet` | Full character metadata, traits, notes, and support notes |
242
+ | `create_character_sheet` | Create a canonical character sheet folder and sidecar |
115
243
  | `list_places` | All places |
244
+ | `get_place_sheet` | Full place metadata, tags, associated characters, notes, and support notes |
245
+ | `create_place_sheet` | Create a canonical place sheet folder and sidecar |
116
246
  | `search_metadata` | Full-text search across scene titles and loglines |
117
247
  | `list_threads` | All subplot threads for a project |
118
248
  | `get_thread_arc` | Scenes belonging to a thread, with per-thread beat |
@@ -121,6 +251,11 @@ Universe-level characters and places are shared across all books in that univers
121
251
  | `update_scene_metadata` | Write metadata fields back to a scene sidecar |
122
252
  | `update_character_sheet` | Write fields back to a character sidecar |
123
253
  | `flag_scene` | Mark a scene with a flag for AI follow-up |
254
+ | `propose_edit` | Stage a scene revision for review without writing it |
255
+ | `commit_edit` | Apply a staged prose edit and create a git-backed snapshot |
256
+ | `discard_edit` | Discard a pending staged prose edit |
257
+ | `snapshot_scene` | Create a manual git snapshot for a scene |
258
+ | `list_snapshots` | List snapshot history for a scene |
124
259
 
125
260
  Paginated tools (`find_scenes`, `get_arc`, `list_threads`, `get_thread_arc`, `search_metadata`) accept `page` and `page_size` arguments and return `total_count` / `total_pages` in the response envelope.
126
261
 
@@ -166,6 +301,27 @@ npm install
166
301
  WRITING_SYNC_DIR=./my-manuscript DB_PATH=./writing.db npm start
167
302
  ```
168
303
 
304
+ The `npm start` script automatically includes the `--experimental-sqlite` flag needed for SQLite support in Node.js 22+.
305
+
306
+ ## Verify your setup
307
+
308
+ After starting the server, test that it's working:
309
+
310
+ ```sh
311
+ # In a new terminal
312
+ curl http://localhost:3000/healthz
313
+ # Should return: ok
314
+ ```
315
+
316
+ Then test the MCP endpoint:
317
+
318
+ ```sh
319
+ curl http://localhost:3000/sse
320
+ # Should return a stream endpoint: /message?sessionId=<id>
321
+ ```
322
+
323
+ If both return successfully, the server is ready to use.
324
+
169
325
  ## Development
170
326
 
171
327
  ```sh
@@ -173,10 +329,90 @@ npm install
173
329
  npm test # unit + integration tests
174
330
  npm run test:unit # unit tests only (no server required)
175
331
  npm run lint:metadata # lint metadata in WRITING_SYNC_DIR or ./sync
176
- npm run lint:metadata:test # lint fixture metadata in ./test-sync
177
332
  ```
178
333
 
179
- Unit tests use an in-memory SQLite database and temporary directories — no server needed. Integration tests spawn a real server against `test-sync/` on port 3099 and verify all MCP tools end-to-end.
334
+ Unit tests use an in-memory SQLite database and temporary directories — no server needed. Integration tests generate a fixture sync tree at runtime in temporary directories, spawn a real server on port 3099, and verify all MCP tools end-to-end.
335
+
336
+ For real projects, keep your manuscript sync folder outside this tool repository and point `WRITING_SYNC_DIR` at that external path.
337
+
338
+ ## Troubleshooting
339
+
340
+ ### "Module not found: sqlite" or "Database support not available"
341
+
342
+ **Cause:** Node.js version is below 22.6.0 or the `--experimental-sqlite` flag was not passed.
343
+
344
+ **Solution:**
345
+ 1. Check your Node.js version: `node --version` (should be v22.6.0+)
346
+ 2. Update Node.js if needed: use nvm, homebrew, or download from nodejs.org
347
+ 3. Restart with `npm start` (which includes the flag automatically)
348
+
349
+ ### "EADDRINUSE: address already in use :::3000"
350
+
351
+ **Cause:** Port 3000 is already in use by another application.
352
+
353
+ **Solution:**
354
+ Use a different port:
355
+
356
+ ```sh
357
+ HTTP_PORT=3001 WRITING_SYNC_DIR=./my-manuscript DB_PATH=./writing.db npm start
358
+ ```
359
+
360
+ Then update your MCP client config to use `http://localhost:3001/sse`.
361
+
362
+ ### "ENOENT: no such file or directory, open './writing.db'"
363
+
364
+ **Cause:** The directory for `DB_PATH` does not exist.
365
+
366
+ **Solution:**
367
+ Create the directory first:
368
+
369
+ ```sh
370
+ mkdir -p $(dirname ./writing.db) # if using a subdirectory
371
+ WRITING_SYNC_DIR=./my-manuscript DB_PATH=./writing.db npm start
372
+ ```
373
+
374
+ Or use an absolute path:
375
+
376
+ ```sh
377
+ WRITING_SYNC_DIR=~/my-manuscript DB_PATH=~/writing-data/writing.db npm start
378
+ ```
379
+
380
+ ### "Sync dir not found: ./my-manuscript"
381
+
382
+ **Cause:** The `WRITING_SYNC_DIR` path does not exist.
383
+
384
+ **Solution:**
385
+ Create the sync folder first:
386
+
387
+ ```sh
388
+ mkdir -p ./my-manuscript/projects/my-novel
389
+ WRITING_SYNC_DIR=./my-manuscript DB_PATH=./writing.db npm start
390
+ ```
391
+
392
+ Or point to an existing folder where you've already placed scene files.
393
+
394
+ ### "Import failed: unrecognized format"
395
+
396
+ **Cause:** The Scrivener export format was not plain text (`.txt`) or the folder structure is unexpected.
397
+
398
+ **Solution:**
399
+ 1. In Scrivener, re-export with **File → Sync → With External Folder**
400
+ 2. Ensure the format is set to **Plain text** (not RTF or .docx)
401
+ 3. Verify the export folder has a `Draft/` subdirectory with `.txt` files
402
+ 4. Try the import again: `node scripts/import.js ~/my-novel-txt /path/to/sync-dir --project my-novel`
403
+
404
+ ### Tests fail after updating Node.js
405
+
406
+ **Cause:** SQLite module cache may be stale.
407
+
408
+ **Solution:**
409
+ Clear npm cache and reinstall:
410
+
411
+ ```sh
412
+ rm -rf node_modules package-lock.json
413
+ npm install
414
+ npm test
415
+ ```
180
416
 
181
417
  ## Environment variables
182
418
 
package/git.js ADDED
@@ -0,0 +1,190 @@
1
+ import { execSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ /**
6
+ * Check if a directory is itself the root of a git repository (not just inside one).
7
+ * This prevents mcp-writing's own .git from being used for prose snapshots when
8
+ * WRITING_SYNC_DIR is a subdirectory of the code repo.
9
+ */
10
+ export function isGitRepository(dirPath) {
11
+ try {
12
+ const gitRoot = execSync("git rev-parse --show-toplevel", {
13
+ cwd: dirPath,
14
+ stdio: "pipe",
15
+ encoding: "utf8",
16
+ }).trim();
17
+ return path.resolve(gitRoot) === path.resolve(dirPath);
18
+ } catch {
19
+ return false;
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Initialize a git repository in a directory
25
+ */
26
+ export function initGitRepository(dirPath) {
27
+ try {
28
+ execSync("git init", { cwd: dirPath, stdio: "pipe" });
29
+ // Set a dummy user config for commits if not already set
30
+ try {
31
+ execSync("git config user.email", { cwd: dirPath, stdio: "pipe" });
32
+ } catch {
33
+ execSync("git config user.email writing-mcp@local", { cwd: dirPath, stdio: "pipe" });
34
+ execSync("git config user.name writing-mcp", { cwd: dirPath, stdio: "pipe" });
35
+ }
36
+ return true;
37
+ } catch (err) {
38
+ throw new Error(`Failed to initialize git repository: ${err.message}`);
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Check if git is available on PATH
44
+ */
45
+ export function isGitAvailable() {
46
+ try {
47
+ execSync("git --version", { stdio: "pipe" });
48
+ return true;
49
+ } catch {
50
+ return false;
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Create a git commit for a scene file (pre-edit snapshot)
56
+ * Returns { commit_hash: string, commit_message: string }
57
+ */
58
+ export function createSnapshot(dirPath, filePath, sceneId, instruction) {
59
+ try {
60
+ const relPath = path.relative(dirPath, filePath);
61
+ execSync(`git add "${relPath}"`, { cwd: dirPath, stdio: "pipe" });
62
+
63
+ const commitMessage = `pre-edit snapshot: ${sceneId} — ${instruction}`;
64
+ // Use 2>&1 so git's stderr (where it prints "[branch hash] msg") is captured in stdout
65
+ const output = execSync(`git commit -m "${commitMessage.replace(/"/g, '\\"')}" 2>&1`, {
66
+ cwd: dirPath,
67
+ encoding: "utf8",
68
+ stdio: "pipe",
69
+ });
70
+
71
+ // git outputs "[branch hash] message" to stderr; redirect 2>&1 captures it in stdout
72
+ // Regex handles any branch name, with or without (root-commit)
73
+ const match = output.match(/\[\S+(?:\s+\(root-commit\))?\s+([a-f0-9]+)\]/);
74
+ const commitHash = match ? match[1] : null;
75
+
76
+ return {
77
+ commit_hash: commitHash,
78
+ commit_message: commitMessage,
79
+ };
80
+ } catch (err) {
81
+ // Check if nothing changed (no error, just no commit)
82
+ if (err.message.includes("nothing to commit") || err.status === 1) {
83
+ return {
84
+ commit_hash: null,
85
+ commit_message: null,
86
+ reason: "no changes to commit",
87
+ };
88
+ }
89
+ throw new Error(`Failed to create snapshot: ${err.message}`);
90
+ }
91
+ }
92
+
93
+ /**
94
+ * List git commits for a file, with timestamps and messages
95
+ * Returns array of { commit_hash, timestamp, message }
96
+ */
97
+ export function listSnapshots(dirPath, filePath) {
98
+ try {
99
+ const relPath = path.relative(dirPath, filePath);
100
+ const output = execSync(
101
+ `git log --pretty=format:"%h|%ai|%s" -- "${relPath}"`,
102
+ {
103
+ cwd: dirPath,
104
+ stdio: "pipe",
105
+ encoding: "utf8",
106
+ }
107
+ );
108
+
109
+ if (!output) return [];
110
+
111
+ return output
112
+ .split("\n")
113
+ .filter(Boolean)
114
+ .map((line) => {
115
+ const [hash, timestamp, ...messageParts] = line.split("|");
116
+ return {
117
+ commit_hash: hash.trim(),
118
+ timestamp: timestamp.trim(),
119
+ message: messageParts.join("|").trim(),
120
+ };
121
+ });
122
+ } catch (err) {
123
+ // If there are no commits yet, return empty array
124
+ if (err.message.includes("your current branch") || err.status === 128) {
125
+ return [];
126
+ }
127
+ throw new Error(`Failed to list snapshots: ${err.message}`);
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Get prose content from a specific git commit
133
+ * If commit is null, returns current working tree version
134
+ */
135
+ export function getSceneProseAtCommit(dirPath, filePath, commitHash) {
136
+ try {
137
+ const relPath = path.relative(dirPath, filePath);
138
+
139
+ if (!commitHash) {
140
+ // Return current working tree version
141
+ return fs.readFileSync(filePath, "utf8");
142
+ }
143
+
144
+ // Get version from git
145
+ const content = execSync(`git show ${commitHash}:"${relPath}"`, {
146
+ cwd: dirPath,
147
+ stdio: "pipe",
148
+ encoding: "utf8",
149
+ });
150
+
151
+ return content;
152
+ } catch (err) {
153
+ if (err.code === "ENOENT") {
154
+ throw new Error(`File not found in commit ${commitHash}`);
155
+ }
156
+ throw new Error(`Failed to retrieve scene prose: ${err.message}`);
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Check if working tree is clean (no uncommitted changes)
162
+ */
163
+ export function isWorkingTreeClean(dirPath) {
164
+ try {
165
+ const output = execSync("git status --porcelain", {
166
+ cwd: dirPath,
167
+ stdio: "pipe",
168
+ encoding: "utf8",
169
+ });
170
+ return output.trim() === "";
171
+ } catch {
172
+ return false;
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Get the HEAD commit hash
178
+ */
179
+ export function getHeadCommitHash(dirPath) {
180
+ try {
181
+ const hash = execSync("git rev-parse HEAD", {
182
+ cwd: dirPath,
183
+ stdio: "pipe",
184
+ encoding: "utf8",
185
+ }).trim();
186
+ return hash;
187
+ } catch {
188
+ return null;
189
+ }
190
+ }