@hanna84/mcp-writing 2.18.0 → 3.0.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.
@@ -1,5 +1,6 @@
1
1
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
2
  import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
3
+ import { callToolParsed } from "./mcp-result.mjs";
3
4
 
4
5
  const BASE_URL = "http://localhost:3000";
5
6
 
@@ -22,8 +23,8 @@ async function connect() {
22
23
  }
23
24
 
24
25
  async function callTool(client, name, args = {}) {
25
- const result = await client.callTool({ name, arguments: args });
26
- return result.content?.[0]?.text ?? "";
26
+ const result = await callToolParsed(client, name, args);
27
+ return result.text;
27
28
  }
28
29
 
29
30
  const results = [];
@@ -45,12 +46,9 @@ async function runScenarios() {
45
46
  try {
46
47
  const text = await callTool(client, "find_scenes", { project_id: "the-lamb" });
47
48
  const parsed = JSON.parse(text);
48
- const isArray = Array.isArray(parsed);
49
- const isEnvelope = !isArray && parsed.results && parsed.total_count !== undefined;
50
-
51
- if (isArray) {
52
- log("1a", "PASS", `Got array of ${parsed.length} scenes (backward compat)`);
53
- } else if (isEnvelope) {
49
+ const isEnvelope = parsed && parsed.results && parsed.total_count !== undefined;
50
+
51
+ if (isEnvelope) {
54
52
  log("1b", "PASS", `Got envelope with results: ${parsed.results.length}, total_count: ${parsed.total_count}`);
55
53
  } else {
56
54
  log("1c", "FAIL", `Unknown shape: ${JSON.stringify(parsed).slice(0, 100)}`);
@@ -241,17 +239,23 @@ async function runScenarios() {
241
239
  const ga = JSON.parse(await callTool(client, "get_arc", { character_id: "char-mira-nystrom", page_size: 1 }));
242
240
  const sm = JSON.parse(await callTool(client, "search_metadata", { query: "scene", page_size: 1 }));
243
241
  const lt = JSON.parse(await callTool(client, "list_threads", { project_id: "the-lamb" }));
242
+ const lc = JSON.parse(await callTool(client, "list_characters", { project_id: "the-lamb" }));
243
+ const lp = JSON.parse(await callTool(client, "list_places", { project_id: "the-lamb" }));
244
+ const sr = JSON.parse(await callTool(client, "search_reference", { query: "scene" }));
244
245
 
245
246
  const commonFields = ["results", "total_count"];
246
247
  const fsHas = commonFields.every(f => f in fs);
247
248
  const gaHas = commonFields.every(f => f in ga);
248
249
  const smHas = commonFields.every(f => f in sm);
249
250
  const ltHas = commonFields.every(f => f in lt);
251
+ const lcHas = commonFields.every(f => f in lc);
252
+ const lpHas = commonFields.every(f => f in lp);
253
+ const srHas = commonFields.every(f => f in sr);
250
254
 
251
- if (fsHas && gaHas && smHas && ltHas) {
252
- log("11", "PASS", `All tools have results + total_count envelope`);
255
+ if (fsHas && gaHas && smHas && ltHas && lcHas && lpHas && srHas) {
256
+ log("11", "PASS", `All metadata-read tools have results + total_count envelope`);
253
257
  } else {
254
- log("11", "FAIL", `Missing fields: fs=${fsHas}, ga=${gaHas}, sm=${smHas}, lt=${ltHas}`);
258
+ log("11", "FAIL", `Missing fields: fs=${fsHas}, ga=${gaHas}, sm=${smHas}, lt=${ltHas}, lc=${lcHas}, lp=${lpHas}, sr=${srHas}`);
255
259
  }
256
260
  } catch (err) {
257
261
  log("11", "FAIL", err.message);
@@ -267,14 +271,22 @@ async function runScenarios() {
267
271
  ga: JSON.parse(await callTool(client, "get_arc", { character_id: "char-mira-nystrom" })),
268
272
  sm: JSON.parse(await callTool(client, "search_metadata", { query: "scene" })),
269
273
  lt: JSON.parse(await callTool(client, "list_threads", { project_id: "the-lamb" })),
274
+ lc: JSON.parse(await callTool(client, "list_characters", { project_id: "the-lamb" })),
275
+ lp: JSON.parse(await callTool(client, "list_places", { project_id: "the-lamb" })),
276
+ sr: JSON.parse(await callTool(client, "search_reference", { query: "scene" })),
270
277
  };
271
278
 
272
- const allValid = Object.values(smoke).every(obj => obj !== null && typeof obj === "object");
279
+ const allValid = Object.values(smoke).every(obj => (
280
+ obj !== null
281
+ && typeof obj === "object"
282
+ && "results" in obj
283
+ && "total_count" in obj
284
+ ));
273
285
 
274
286
  if (allValid) {
275
- log("12", "PASS", `All 4 tools parsed as valid JSON objects`);
287
+ log("12", "PASS", `All sampled metadata-read tools returned envelope responses`);
276
288
  } else {
277
- log("12", "FAIL", `Some responses did not parse as JSON`);
289
+ log("12", "FAIL", `Some responses did not match envelope shape`);
278
290
  }
279
291
  } catch (err) {
280
292
  log("12", "FAIL", err.message);
@@ -1,5 +1,6 @@
1
1
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
2
  import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
3
+ import { callToolParsed } from "./mcp-result.mjs";
3
4
 
4
5
  const BASE_URL = "http://localhost:3000";
5
6
 
@@ -12,9 +13,10 @@ let failed = 0;
12
13
 
13
14
  async function test(label, toolName, args, check) {
14
15
  try {
15
- const result = await client.callTool({ name: toolName, arguments: args });
16
- const text = result.content?.[0]?.text ?? "";
17
- const ok = check(text);
16
+ const parsed = await callToolParsed(client, toolName, args);
17
+ const text = parsed.text;
18
+ const result = parsed.raw;
19
+ const ok = check(text, result);
18
20
  if (ok) {
19
21
  console.log(` ✓ ${label}`);
20
22
  passed++;
@@ -77,7 +79,12 @@ console.log("\n── get_scene_prose ──────────────
77
79
  await test(
78
80
  "returns prose for sc-001",
79
81
  "get_scene_prose", { scene_id: "sc-001" },
80
- t => t.includes("gangway") && t.includes("Marcus")
82
+ (t, result) => {
83
+ if (result.structuredContent?.warning) {
84
+ console.log(` warning: ${result.structuredContent.warning}`);
85
+ }
86
+ return t.includes("gangway") && t.includes("Marcus");
87
+ }
81
88
  );
82
89
 
83
90
  await test(
@@ -7,6 +7,7 @@ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
7
7
  import { URL as NodeURL } from "node:url";
8
8
  import { execSync } from "node:child_process";
9
9
  import fs from "node:fs";
10
+ import { callToolParsed } from "./manual/mcp-result.mjs";
10
11
 
11
12
  const ROOT = process.cwd();
12
13
 
@@ -38,7 +39,8 @@ async function connectClient(url) {
38
39
 
39
40
  async function callTool(client, name, args = {}) {
40
41
  try {
41
- return await client.callTool({ name, arguments: args });
42
+ const parsed = await callToolParsed(client, name, args);
43
+ return parsed.raw;
42
44
  } catch (e) {
43
45
  return { error: e.message };
44
46
  }
@@ -48,12 +50,14 @@ function parseResponse(result) {
48
50
  if (result.error) return { error: result.error };
49
51
  try {
50
52
  const text = result.content?.[0]?.text;
53
+ const structured = result.structuredContent;
51
54
  if (!text) return { raw: result };
52
55
  // Try to parse as JSON
53
56
  try {
54
- return JSON.parse(text);
57
+ const parsed = JSON.parse(text);
58
+ return structured ? { ...parsed, _structured: structured } : parsed;
55
59
  } catch {
56
- return { text };
60
+ return structured ? { text, _structured: structured } : { text };
57
61
  }
58
62
  } catch {
59
63
  return { raw: result };
@@ -218,7 +222,10 @@ async function runPhaseB() {
218
222
  const proseRes = await callTool(client, "get_scene_prose", { scene_id: results.firstSceneId });
219
223
  const proseData = parseResponse(proseRes);
220
224
  results.proseExcerpt = proseData.prose?.slice(0, 200) || proseData.text?.slice(0, 200) || proseRes.content?.[0]?.text?.slice(0, 200) || "(no prose)";
225
+ results.proseWarning = proseData._structured?.warning || null;
226
+ results.proseNextStep = proseData._structured?.next_step || null;
221
227
  console.log("get_scene_prose excerpt:", results.proseExcerpt.slice(0, 150) + "...");
228
+ if (results.proseWarning) console.log("get_scene_prose warning:", results.proseWarning);
222
229
  } else {
223
230
  results.proseExcerpt = "(no scene_id available)";
224
231
  }
@@ -260,6 +267,7 @@ async function main() {
260
267
  console.log(" Place count:", phaseB.placeCount);
261
268
  console.log(" Airport search count:", phaseB.airportSearchCount);
262
269
  console.log(" First scene_id:", phaseB.firstSceneId || "N/A");
270
+ if (phaseB.proseWarning) console.log(" Prose warning:", phaseB.proseWarning);
263
271
  console.log(" Prose excerpt (200 chars):", phaseB.proseExcerpt?.slice(0, 200) || "N/A");
264
272
  if (phaseB.warnings.length) console.log(" Lint warnings:", phaseB.warnings.length > 0 ? "Yes (see above)" : "None");
265
273
  if (phaseB.errors.length) console.log(" Errors:", phaseB.errors);
@@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
2
2
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3
3
  import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
4
4
  import { URL as NodeURL } from "node:url";
5
+ import { callToolParsed } from "./manual/mcp-result.mjs";
5
6
 
6
7
  const ROOT = process.cwd();
7
8
 
@@ -31,11 +32,11 @@ try {
31
32
  const transport = new SSEClientTransport(new NodeURL(`${BASE}/sse`));
32
33
  await client.connect(transport);
33
34
 
34
- await client.callTool({ name: "sync", arguments: {} });
35
+ await callToolParsed(client, "sync", {});
35
36
 
36
- const scenes = await client.callTool({ name: "find_scenes", arguments: { project_id: "scrivener-export", page_size: 3, page: 1 } });
37
+ const scenes = await callToolParsed(client, "find_scenes", { project_id: "scrivener-export", page_size: 3, page: 1 });
37
38
  console.log("=== find_scenes raw response ===");
38
- console.log(JSON.stringify(scenes, null, 2));
39
+ console.log(JSON.stringify(scenes.raw, null, 2));
39
40
 
40
41
  await client.close();
41
42
  } finally {
package/src/sync/sync.js CHANGED
@@ -919,13 +919,10 @@ function pruneMissingScenes(db, seenSceneKeys, syncDir) {
919
919
  WHERE source_kind = 'scene' AND source_project_id = ? AND source_id = ?
920
920
  `).run(row.project_id ?? "", row.scene_id);
921
921
  db.prepare(`DELETE FROM scenes WHERE scene_id = ? AND project_id = ?`).run(row.scene_id, row.project_id);
922
-
923
- const remainingScene = db.prepare(`SELECT 1 FROM scenes WHERE scene_id = ? LIMIT 1`).get(row.scene_id);
924
- if (!remainingScene) {
925
- db.prepare(`DELETE FROM scene_characters WHERE scene_id = ?`).run(row.scene_id);
926
- db.prepare(`DELETE FROM scene_places WHERE scene_id = ?`).run(row.scene_id);
927
- db.prepare(`DELETE FROM scene_tags WHERE scene_id = ?`).run(row.scene_id);
928
- }
922
+ db.prepare(`DELETE FROM scene_characters WHERE scene_id = ? AND project_id = ?`).run(row.scene_id, row.project_id);
923
+ db.prepare(`DELETE FROM scene_places WHERE scene_id = ? AND project_id = ?`).run(row.scene_id, row.project_id);
924
+ db.prepare(`DELETE FROM scene_tags WHERE scene_id = ? AND project_id = ?`).run(row.scene_id, row.project_id);
925
+ db.prepare(`DELETE FROM scene_threads WHERE scene_id = ? AND project_id = ?`).run(row.scene_id, row.project_id);
929
926
  }
930
927
  }
931
928
 
@@ -994,14 +991,14 @@ export function indexSceneFile(db, syncDir, file, meta, prose) {
994
991
  new Date().toISOString()
995
992
  );
996
993
 
997
- db.prepare(`DELETE FROM scene_characters WHERE scene_id = ?`).run(meta.scene_id);
998
- db.prepare(`DELETE FROM scene_places WHERE scene_id = ?`).run(meta.scene_id);
999
- db.prepare(`DELETE FROM scene_tags WHERE scene_id = ?`).run(meta.scene_id);
994
+ db.prepare(`DELETE FROM scene_characters WHERE scene_id = ? AND project_id = ?`).run(meta.scene_id, project_id);
995
+ db.prepare(`DELETE FROM scene_places WHERE scene_id = ? AND project_id = ?`).run(meta.scene_id, project_id);
996
+ db.prepare(`DELETE FROM scene_tags WHERE scene_id = ? AND project_id = ?`).run(meta.scene_id, project_id);
1000
997
 
1001
998
  for (const c of (meta.characters ?? [])) {
1002
999
  // Version continuity markers (e.g. v7.3, v3.3b) are tracked as tags, not characters
1003
1000
  if (/^v\d[\d.a-z]*$/i.test(c)) {
1004
- db.prepare(`INSERT OR IGNORE INTO scene_tags (scene_id, tag) VALUES (?, ?)`).run(meta.scene_id, c);
1001
+ db.prepare(`INSERT OR IGNORE INTO scene_tags (scene_id, project_id, tag) VALUES (?, ?, ?)`).run(meta.scene_id, project_id, c);
1005
1002
  continue;
1006
1003
  }
1007
1004
  let cid = c;
@@ -1021,23 +1018,23 @@ export function indexSceneFile(db, syncDir, file, meta, prose) {
1021
1018
  }
1022
1019
  if (row) cid = row.character_id;
1023
1020
  }
1024
- db.prepare(`INSERT OR IGNORE INTO scene_characters (scene_id, character_id) VALUES (?, ?)`).run(
1025
- meta.scene_id, cid
1021
+ db.prepare(`INSERT OR IGNORE INTO scene_characters (scene_id, project_id, character_id) VALUES (?, ?, ?)`).run(
1022
+ meta.scene_id, project_id, cid
1026
1023
  );
1027
1024
  }
1028
1025
  for (const p of (meta.places ?? [])) {
1029
- db.prepare(`INSERT OR IGNORE INTO scene_places (scene_id, place_id) VALUES (?, ?)`).run(
1030
- meta.scene_id, p
1026
+ db.prepare(`INSERT OR IGNORE INTO scene_places (scene_id, project_id, place_id) VALUES (?, ?, ?)`).run(
1027
+ meta.scene_id, project_id, p
1031
1028
  );
1032
1029
  }
1033
1030
  for (const t of (meta.tags ?? [])) {
1034
- db.prepare(`INSERT OR IGNORE INTO scene_tags (scene_id, tag) VALUES (?, ?)`).run(
1035
- meta.scene_id, t
1031
+ db.prepare(`INSERT OR IGNORE INTO scene_tags (scene_id, project_id, tag) VALUES (?, ?, ?)`).run(
1032
+ meta.scene_id, project_id, t
1036
1033
  );
1037
1034
  }
1038
1035
  for (const v of (meta.versions ?? [])) {
1039
- db.prepare(`INSERT OR IGNORE INTO scene_tags (scene_id, tag) VALUES (?, ?)`).run(
1040
- meta.scene_id, v
1036
+ db.prepare(`INSERT OR IGNORE INTO scene_tags (scene_id, project_id, tag) VALUES (?, ?, ?)`).run(
1037
+ meta.scene_id, project_id, v
1041
1038
  );
1042
1039
  }
1043
1040
 
@@ -25,20 +25,37 @@ export function registerEditingTools(s, {
25
25
  // ---- propose_edit --------------------------------------------------------
26
26
  s.tool(
27
27
  "propose_edit",
28
- "Generate a proposed revision for a scene. Returns a proposal_id and a diff preview. Nothing is written yet — you must call commit_edit to apply the change. This tool requires git to be available.",
28
+ "Generate a proposed revision for a scene. Returns a proposal_id and a diff preview. Nothing is written yet — you must call commit_edit to apply the change. This tool requires git to be available. If scene IDs are reused across projects, omitting project_id returns CONFLICT with candidate project_ids.",
29
29
  {
30
30
  scene_id: z.string().describe("The scene_id to revise (e.g. 'sc-011-sebastian')."),
31
+ project_id: z.string().optional().describe("Optional project ID to disambiguate duplicate scene IDs across projects."),
31
32
  instruction: z.string().describe("A brief instruction for the edit (e.g. 'Tighten the opening paragraph'). Used in the git commit message."),
32
33
  revised_prose: z.string().describe("The complete revised prose text for the scene."),
33
34
  },
34
- async ({ scene_id, instruction, revised_prose }) => {
35
+ async ({ scene_id, project_id, instruction, revised_prose }) => {
35
36
  if (!GIT_ENABLED) {
36
37
  return errorResponse("GIT_UNAVAILABLE", "Git is not available — prose editing is not supported. Ensure git is installed and the sync directory is writable.");
37
38
  }
38
39
 
39
- const scene = db.prepare(`SELECT file_path FROM scenes WHERE scene_id = ?`).get(scene_id);
40
- if (!scene) {
41
- return errorResponse("NOT_FOUND", `Scene '${scene_id}' not found. Hint: call find_scenes to get valid scene IDs.`);
40
+ let scene;
41
+ if (project_id) {
42
+ scene = db.prepare(`SELECT file_path, project_id FROM scenes WHERE scene_id = ? AND project_id = ?`).get(scene_id, project_id);
43
+ if (!scene) {
44
+ return errorResponse("NOT_FOUND", `Scene '${scene_id}' not found in project '${project_id}'. Hint: call find_scenes with project_id to get valid scene IDs.`);
45
+ }
46
+ } else {
47
+ const scenes = db.prepare(`SELECT file_path, project_id FROM scenes WHERE scene_id = ? ORDER BY project_id`).all(scene_id);
48
+ if (scenes.length === 0) {
49
+ return errorResponse("NOT_FOUND", `Scene '${scene_id}' not found. Hint: call find_scenes to get valid scene IDs.`);
50
+ }
51
+ if (scenes.length > 1) {
52
+ return errorResponse(
53
+ "CONFLICT",
54
+ `Scene ID '${scene_id}' exists in multiple projects. Provide project_id to disambiguate.`,
55
+ { scene_id, project_ids: scenes.map(s => s.project_id) }
56
+ );
57
+ }
58
+ scene = scenes[0];
42
59
  }
43
60
 
44
61
  try {
@@ -66,6 +83,7 @@ export function registerEditingTools(s, {
66
83
  const proposalId = generateProposalId();
67
84
  pendingProposals.set(proposalId, {
68
85
  scene_id,
86
+ project_id: scene.project_id,
69
87
  scene_file_path: scene.file_path,
70
88
  instruction,
71
89
  revised_prose,
@@ -85,12 +103,16 @@ export function registerEditingTools(s, {
85
103
  return jsonResponse({
86
104
  proposal_id: proposalId,
87
105
  scene_id,
106
+ project_id: scene.project_id,
88
107
  instruction,
89
108
  diff_preview: diffPreview,
90
109
  noop,
91
110
  note: noop
92
111
  ? "This proposal matches the current scene file. Calling commit_edit will be a no-op."
93
112
  : "Review the diff above. Call commit_edit with this proposal_id to apply the change.",
113
+ next_step: noop
114
+ ? "If this was intentional, no action is required. Otherwise, adjust revised_prose and call propose_edit again."
115
+ : "Review diff_preview, then call commit_edit to apply or discard_edit to reject.",
94
116
  });
95
117
  } catch (err) {
96
118
  if (err.code === "ENOENT") {
@@ -107,9 +129,10 @@ export function registerEditingTools(s, {
107
129
  "Apply a proposed edit and commit it to git. First creates a pre-edit snapshot, then writes the revised prose and metadata back to disk. The scene metadata stale flag is cleared.",
108
130
  {
109
131
  scene_id: z.string().describe("The scene_id being revised."),
132
+ project_id: z.string().optional().describe("Optional project ID. Required when scene IDs are duplicated across projects."),
110
133
  proposal_id: z.string().describe("The proposal_id returned by propose_edit."),
111
134
  },
112
- async ({ scene_id, proposal_id }) => {
135
+ async ({ scene_id, project_id, proposal_id }) => {
113
136
  if (!GIT_ENABLED) {
114
137
  return errorResponse("GIT_UNAVAILABLE", "Git is not available — prose editing is not supported.");
115
138
  }
@@ -123,6 +146,27 @@ export function registerEditingTools(s, {
123
146
  return errorResponse("INVALID_EDIT", `Proposal '${proposal_id}' is for scene '${proposal.scene_id}', not '${scene_id}'.`);
124
147
  }
125
148
 
149
+ if (!project_id && proposal.project_id) {
150
+ const projectMatches = db.prepare(`SELECT project_id FROM scenes WHERE scene_id = ? ORDER BY project_id`).all(scene_id);
151
+ if (projectMatches.length > 1) {
152
+ return errorResponse(
153
+ "CONFLICT",
154
+ `Scene ID '${scene_id}' exists in multiple projects. Provide project_id to disambiguate commit_edit.`,
155
+ {
156
+ scene_id,
157
+ project_ids: projectMatches.map((row) => row.project_id),
158
+ proposal_project_id: proposal.project_id,
159
+ proposal_id,
160
+ next_step: `Retry commit_edit with project_id '${proposal.project_id}' and the same proposal_id.`,
161
+ }
162
+ );
163
+ }
164
+ }
165
+
166
+ if (project_id && proposal.project_id && proposal.project_id !== project_id) {
167
+ return errorResponse("INVALID_EDIT", `Proposal '${proposal_id}' is for project '${proposal.project_id}', not '${project_id}'.`);
168
+ }
169
+
126
170
  try {
127
171
  const proseWriteDiagnostics = getFileWriteDiagnostics(proposal.scene_file_path);
128
172
  if (proseWriteDiagnostics.stat_error_code === "EACCES" || proseWriteDiagnostics.stat_error_code === "EPERM") {
@@ -184,10 +228,12 @@ export function registerEditingTools(s, {
184
228
  return jsonResponse({
185
229
  ok: true,
186
230
  scene_id,
231
+ project_id: proposal.project_id ?? null,
187
232
  proposal_id,
188
233
  snapshot_commit: null,
189
234
  noop: true,
190
235
  message: `Proposal for scene '${scene_id}' matches the current file. Nothing was written.`,
236
+ next_step: "No changes were applied. If you still need edits, call propose_edit with revised prose.",
191
237
  });
192
238
  }
193
239
 
@@ -204,10 +250,12 @@ export function registerEditingTools(s, {
204
250
  return jsonResponse({
205
251
  ok: true,
206
252
  scene_id,
253
+ project_id: proposal.project_id ?? null,
207
254
  proposal_id,
208
255
  snapshot_commit: snapshot.commit_hash,
209
256
  noop: false,
210
257
  message: `Applied edit to scene '${scene_id}'${snapshot.commit_hash ? ` (snapshot: ${snapshot.commit_hash.substring(0, 7)})` : " (no pre-edit snapshot needed)"}`,
258
+ next_step: "Edit applied. Run get_scene_prose to verify prose, then continue with additional targeted edits if needed.",
211
259
  });
212
260
  } catch (err) {
213
261
  if (err.code === "ENOENT") {
@@ -236,6 +284,7 @@ export function registerEditingTools(s, {
236
284
  ok: true,
237
285
  proposal_id,
238
286
  message: `Discarded proposal '${proposal_id}' for scene '${proposal.scene_id}'.`,
287
+ next_step: "Proposal discarded. Call propose_edit again if you want to stage a revised alternative.",
239
288
  });
240
289
  }
241
290
  );
@@ -293,15 +342,32 @@ export function registerEditingTools(s, {
293
342
  "List git commit history for a scene, with timestamps and commit messages. Use this to find commit hashes for get_scene_prose historical retrieval.",
294
343
  {
295
344
  scene_id: z.string().describe("The scene_id to list snapshots for."),
345
+ project_id: z.string().optional().describe("Optional project ID to disambiguate duplicate scene IDs across projects."),
296
346
  },
297
- async ({ scene_id }) => {
347
+ async ({ scene_id, project_id }) => {
298
348
  if (!GIT_ENABLED) {
299
349
  return errorResponse("GIT_UNAVAILABLE", "Git is not available — snapshots cannot be retrieved.");
300
350
  }
301
351
 
302
- const scene = db.prepare(`SELECT file_path FROM scenes WHERE scene_id = ?`).get(scene_id);
303
- if (!scene) {
304
- return errorResponse("NOT_FOUND", `Scene '${scene_id}' not found.`);
352
+ let scene;
353
+ if (project_id) {
354
+ scene = db.prepare(`SELECT file_path, project_id FROM scenes WHERE scene_id = ? AND project_id = ?`).get(scene_id, project_id);
355
+ if (!scene) {
356
+ return errorResponse("NOT_FOUND", `Scene '${scene_id}' not found in project '${project_id}'.`);
357
+ }
358
+ } else {
359
+ const scenes = db.prepare(`SELECT file_path, project_id FROM scenes WHERE scene_id = ? ORDER BY project_id`).all(scene_id);
360
+ if (scenes.length === 0) {
361
+ return errorResponse("NOT_FOUND", `Scene '${scene_id}' not found.`);
362
+ }
363
+ if (scenes.length > 1) {
364
+ return errorResponse(
365
+ "CONFLICT",
366
+ `Scene ID '${scene_id}' exists in multiple projects. Provide project_id to disambiguate.`,
367
+ { scene_id, project_ids: scenes.map((row) => row.project_id) }
368
+ );
369
+ }
370
+ scene = scenes[0];
305
371
  }
306
372
 
307
373
  try {
@@ -312,6 +378,7 @@ export function registerEditingTools(s, {
312
378
 
313
379
  return jsonResponse({
314
380
  scene_id,
381
+ project_id: scene.project_id ?? null,
315
382
  snapshots: snapshots.map(s => ({
316
383
  commit_hash: s.commit_hash,
317
384
  short_hash: s.commit_hash.substring(0, 7),
@@ -347,15 +347,15 @@ export function registerMetadataTools(s, {
347
347
  `).run(thread_id, project_id, thread_name, status ?? "active");
348
348
 
349
349
  db.prepare(`
350
- INSERT INTO scene_threads (scene_id, thread_id, beat)
351
- VALUES (?, ?, ?)
352
- ON CONFLICT (scene_id, thread_id) DO UPDATE SET
350
+ INSERT INTO scene_threads (scene_id, project_id, thread_id, beat)
351
+ VALUES (?, ?, ?, ?)
352
+ ON CONFLICT (scene_id, project_id, thread_id) DO UPDATE SET
353
353
  beat = excluded.beat
354
- `).run(scene_id, thread_id, beat ?? null);
354
+ `).run(scene_id, project_id, thread_id, beat ?? null);
355
355
 
356
356
  const thread = db.prepare(`SELECT * FROM threads WHERE thread_id = ?`).get(thread_id);
357
- const link = db.prepare(`SELECT scene_id, thread_id, beat FROM scene_threads WHERE scene_id = ? AND thread_id = ?`)
358
- .get(scene_id, thread_id);
357
+ const link = db.prepare(`SELECT scene_id, project_id, thread_id, beat FROM scene_threads WHERE scene_id = ? AND project_id = ? AND thread_id = ?`)
358
+ .get(scene_id, project_id, thread_id);
359
359
 
360
360
  return jsonResponse({
361
361
  ok: true,
@@ -74,7 +74,12 @@ export function registerReviewBundleTools(s, {
74
74
  bundle_name,
75
75
  format,
76
76
  });
77
- return jsonResponse(plan);
77
+ return jsonResponse({
78
+ ...plan,
79
+ next_step: plan.strictness_result?.can_proceed
80
+ ? "Preview complete. Review warnings and planned_outputs, then call create_review_bundle with the same scope and output_dir."
81
+ : "Preview complete, but strictness blockers are present. Resolve blockers (for example stale metadata) or switch to strictness='warn' before create_review_bundle.",
82
+ });
78
83
  } catch (error) {
79
84
  if (error instanceof ReviewBundlePlanError) {
80
85
  return errorResponse(error.code, error.message, error.details);
@@ -172,7 +177,11 @@ export function registerReviewBundleTools(s, {
172
177
  return errorResponse(
173
178
  "STRICTNESS_BLOCKED",
174
179
  "Bundle generation blocked by strictness policy.",
175
- { strictness_result: plan.strictness_result, warning_summary: plan.warning_summary }
180
+ {
181
+ strictness_result: plan.strictness_result,
182
+ warning_summary: plan.warning_summary,
183
+ next_step: "Resolve blockers from strictness_result (for example by running enrich_scene on stale scenes), then re-run create_review_bundle.",
184
+ }
176
185
  );
177
186
  }
178
187
 
@@ -200,6 +209,7 @@ export function registerReviewBundleTools(s, {
200
209
  generated_at: artifacts.generated_at,
201
210
  project_id: plan.resolved_scope.project_id,
202
211
  },
212
+ next_step: "Bundle created. Share output_paths with reviewers, or run preview_review_bundle again to adjust scope/profile before regenerating.",
203
213
  });
204
214
  } catch (error) {
205
215
  if (error instanceof ReviewBundlePlanError) {