@hanna84/mcp-writing 3.10.0 → 3.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/package.json +1 -1
- package/src/core/chapter-resolution.js +156 -28
- package/src/core/helpers.js +0 -6
- package/src/review-bundles/review-bundles-planner.js +55 -11
- package/src/structure/scene-chapter-assignment.js +77 -0
- package/src/tools/metadata.js +87 -0
- package/src/tools/review-bundles.js +4 -4
- package/src/tools/search.js +4 -2
- package/src/tools/styleguide.js +2 -2
- package/src/tools/sync.js +1 -1
- package/src/workflows/workflow-catalogue.js +11 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,9 +4,23 @@ All notable changes to this project will be documented in this file. Dates are d
|
|
|
4
4
|
|
|
5
5
|
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
|
6
6
|
|
|
7
|
+
#### [v3.12.0](https://github.com/hannasdev/mcp-writing/compare/v3.11.0...v3.12.0)
|
|
8
|
+
|
|
9
|
+
- feat(structure): normalize chapter compatibility resolution [`#204`](https://github.com/hannasdev/mcp-writing/pull/204)
|
|
10
|
+
|
|
11
|
+
#### [v3.11.0](https://github.com/hannasdev/mcp-writing/compare/v3.10.0...v3.11.0)
|
|
12
|
+
|
|
13
|
+
> 18 May 2026
|
|
14
|
+
|
|
15
|
+
- feat(structure): add explicit scene chapter assignment [`#203`](https://github.com/hannasdev/mcp-writing/pull/203)
|
|
16
|
+
- Release 3.11.0 [`7e73355`](https://github.com/hannasdev/mcp-writing/commit/7e73355bb4bb38845daf90e0d438e3c7e3c9c268)
|
|
17
|
+
|
|
7
18
|
#### [v3.10.0](https://github.com/hannasdev/mcp-writing/compare/v3.9.5...v3.10.0)
|
|
8
19
|
|
|
20
|
+
> 18 May 2026
|
|
21
|
+
|
|
9
22
|
- feat: add read-only structure diagnostics [`#202`](https://github.com/hannasdev/mcp-writing/pull/202)
|
|
23
|
+
- Release 3.10.0 [`d9b1dfb`](https://github.com/hannasdev/mcp-writing/commit/d9b1dfbce6e0391d035725f3c0671253f53ad455)
|
|
10
24
|
|
|
11
25
|
#### [v3.9.5](https://github.com/hannasdev/mcp-writing/compare/v3.9.4...v3.9.5)
|
|
12
26
|
|
package/package.json
CHANGED
|
@@ -1,29 +1,85 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
}
|
|
10
|
-
|
|
1
|
+
function chapterNotFoundError({ chapterNumber, chapterId }) {
|
|
2
|
+
return {
|
|
3
|
+
code: "NOT_FOUND",
|
|
4
|
+
message: "Chapter not found for the provided project and identifier.",
|
|
5
|
+
details: {
|
|
6
|
+
chapter: chapterNumber ?? null,
|
|
7
|
+
chapter_id: chapterId ?? null,
|
|
8
|
+
},
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function ambiguousChapterError({ projectId, chapterNumber, candidates }) {
|
|
13
|
+
return {
|
|
14
|
+
code: "AMBIGUOUS_CHAPTER",
|
|
15
|
+
message: `Compatibility chapter ${chapterNumber} resolves to multiple canonical chapter identities in project '${projectId}'. Use chapter_id instead.`,
|
|
16
|
+
details: {
|
|
17
|
+
project_id: projectId,
|
|
18
|
+
chapter: chapterNumber,
|
|
19
|
+
candidate_chapter_ids: candidates.map(row => row.chapter_id),
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function selectCanonicalChapterById(db, { projectId, chapterId }) {
|
|
25
|
+
return db.prepare(`
|
|
26
|
+
SELECT chapter_id, project_id, title, sort_index, logline, metadata_stale
|
|
27
|
+
FROM chapters
|
|
28
|
+
WHERE project_id = ? AND chapter_id = ?
|
|
29
|
+
`).get(projectId, chapterId);
|
|
30
|
+
}
|
|
11
31
|
|
|
12
|
-
|
|
32
|
+
function selectCanonicalChaptersByNumber(db, { projectId, chapterNumber }) {
|
|
33
|
+
return db.prepare(`
|
|
13
34
|
SELECT chapter_id, project_id, title, sort_index, logline, metadata_stale
|
|
14
35
|
FROM chapters
|
|
15
36
|
WHERE project_id = ? AND sort_index = ?
|
|
16
|
-
|
|
17
|
-
|
|
37
|
+
ORDER BY chapter_id
|
|
38
|
+
`).all(projectId, chapterNumber);
|
|
39
|
+
}
|
|
18
40
|
|
|
41
|
+
function selectSceneDerivedChaptersByNumber(db, { projectId, chapterNumber }) {
|
|
19
42
|
return db.prepare(`
|
|
20
|
-
SELECT
|
|
43
|
+
SELECT
|
|
44
|
+
chapter_id,
|
|
45
|
+
project_id,
|
|
46
|
+
COALESCE(MAX(chapter_title), chapter_id) AS title,
|
|
47
|
+
chapter AS sort_index,
|
|
48
|
+
NULL AS logline,
|
|
49
|
+
MAX(metadata_stale) AS metadata_stale
|
|
21
50
|
FROM scenes
|
|
22
|
-
WHERE project_id = ?
|
|
23
|
-
|
|
51
|
+
WHERE project_id = ?
|
|
52
|
+
AND chapter = ?
|
|
53
|
+
AND chapter_id IS NOT NULL
|
|
54
|
+
AND chapter_id != ''
|
|
55
|
+
GROUP BY chapter_id, project_id, chapter
|
|
24
56
|
ORDER BY chapter_id
|
|
25
|
-
|
|
26
|
-
|
|
57
|
+
`).all(projectId, chapterNumber);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function resolveChapterByCompatibilityKey(db, { projectId, chapterNumber, chapterId }) {
|
|
61
|
+
const result = resolveChapterByCompatibilityKeyDetailed(db, { projectId, chapterNumber, chapterId });
|
|
62
|
+
return result?.chapter ?? null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function resolveChapterByCompatibilityKeyDetailed(db, { projectId, chapterNumber, chapterId }) {
|
|
66
|
+
if (!projectId) return { chapter: null };
|
|
67
|
+
if (chapterId) {
|
|
68
|
+
return { chapter: selectCanonicalChapterById(db, { projectId, chapterId }) ?? null };
|
|
69
|
+
}
|
|
70
|
+
if (chapterNumber == null) return { chapter: null };
|
|
71
|
+
|
|
72
|
+
const canonicalChapters = selectCanonicalChaptersByNumber(db, { projectId, chapterNumber });
|
|
73
|
+
if (canonicalChapters.length > 1) {
|
|
74
|
+
return { error: ambiguousChapterError({ projectId, chapterNumber, candidates: canonicalChapters }) };
|
|
75
|
+
}
|
|
76
|
+
if (canonicalChapters.length === 1) return { chapter: canonicalChapters[0] };
|
|
77
|
+
|
|
78
|
+
const sceneDerivedChapters = selectSceneDerivedChaptersByNumber(db, { projectId, chapterNumber });
|
|
79
|
+
if (sceneDerivedChapters.length > 1) {
|
|
80
|
+
return { error: ambiguousChapterError({ projectId, chapterNumber, candidates: sceneDerivedChapters }) };
|
|
81
|
+
}
|
|
82
|
+
return { chapter: sceneDerivedChapters[0] ?? null };
|
|
27
83
|
}
|
|
28
84
|
|
|
29
85
|
export function resolveValidatedChapterFilter(db, { projectId, chapterNumber, chapterId }) {
|
|
@@ -31,31 +87,103 @@ export function resolveValidatedChapterFilter(db, { projectId, chapterNumber, ch
|
|
|
31
87
|
if (!chapterId && chapterNumber == null) return { chapter: null };
|
|
32
88
|
|
|
33
89
|
const resolvedById = chapterId
|
|
34
|
-
?
|
|
90
|
+
? resolveChapterByCompatibilityKeyDetailed(db, { projectId, chapterId })
|
|
35
91
|
: null;
|
|
36
92
|
const resolvedByNumber = chapterNumber != null
|
|
37
|
-
?
|
|
93
|
+
? resolveChapterByCompatibilityKeyDetailed(db, { projectId, chapterNumber })
|
|
38
94
|
: null;
|
|
39
95
|
|
|
96
|
+
if (resolvedById?.error) return { error: resolvedById.error };
|
|
97
|
+
if (resolvedByNumber?.error) return { error: resolvedByNumber.error };
|
|
98
|
+
|
|
99
|
+
if (chapterId && !resolvedById?.chapter) {
|
|
100
|
+
return { error: chapterNotFoundError({ chapterNumber, chapterId }) };
|
|
101
|
+
}
|
|
102
|
+
if (chapterNumber != null && !resolvedByNumber?.chapter) {
|
|
103
|
+
return { error: chapterNotFoundError({ chapterNumber, chapterId }) };
|
|
104
|
+
}
|
|
105
|
+
|
|
40
106
|
if (chapterId && chapterNumber != null) {
|
|
41
|
-
if (
|
|
107
|
+
if (resolvedById.chapter.chapter_id !== resolvedByNumber.chapter.chapter_id) {
|
|
42
108
|
return {
|
|
43
109
|
error: {
|
|
44
|
-
code: "
|
|
45
|
-
message: "
|
|
110
|
+
code: "VALIDATION_ERROR",
|
|
111
|
+
message: "chapter_id and chapter must refer to the same canonical chapter when both are provided.",
|
|
112
|
+
details: {
|
|
113
|
+
chapter_id: resolvedById.chapter.chapter_id,
|
|
114
|
+
chapter: chapterNumber,
|
|
115
|
+
resolved_chapter_id: resolvedByNumber.chapter.chapter_id,
|
|
116
|
+
},
|
|
46
117
|
},
|
|
47
118
|
};
|
|
48
119
|
}
|
|
49
|
-
|
|
120
|
+
return { chapter: resolvedById.chapter };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return { chapter: resolvedById?.chapter ?? resolvedByNumber?.chapter ?? null };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function resolveValidatedChapterNumberFilters(db, { projectId, chapterNumbers }) {
|
|
127
|
+
if (!projectId || chapterNumbers == null) return { chapters: [] };
|
|
128
|
+
const invalidChapterNumbers = chapterNumbers.filter(value => !Number.isInteger(value));
|
|
129
|
+
if (invalidChapterNumbers.length > 0) {
|
|
130
|
+
return {
|
|
131
|
+
error: {
|
|
132
|
+
code: "VALIDATION_ERROR",
|
|
133
|
+
message: "chapters must contain only integer chapter numbers.",
|
|
134
|
+
details: {
|
|
135
|
+
project_id: projectId,
|
|
136
|
+
invalid_chapters: invalidChapterNumbers,
|
|
137
|
+
requested_chapters: chapterNumbers,
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
const normalizedChapterNumbers = Array.from(new Set(chapterNumbers)).sort((a, b) => a - b);
|
|
143
|
+
const chapters = [];
|
|
144
|
+
const seenChapterIds = new Set();
|
|
145
|
+
|
|
146
|
+
for (const chapterNumber of normalizedChapterNumbers) {
|
|
147
|
+
const resolved = resolveValidatedChapterFilter(db, {
|
|
148
|
+
projectId,
|
|
149
|
+
chapterNumber,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
if (resolved.error) {
|
|
50
153
|
return {
|
|
51
154
|
error: {
|
|
52
|
-
|
|
53
|
-
|
|
155
|
+
...resolved.error,
|
|
156
|
+
details: {
|
|
157
|
+
...(resolved.error.details ?? {}),
|
|
158
|
+
project_id: projectId,
|
|
159
|
+
requested_chapters: normalizedChapterNumbers,
|
|
160
|
+
},
|
|
54
161
|
},
|
|
55
162
|
};
|
|
56
163
|
}
|
|
57
|
-
|
|
164
|
+
|
|
165
|
+
if (!resolved.chapter) {
|
|
166
|
+
return {
|
|
167
|
+
error: {
|
|
168
|
+
code: "NOT_FOUND",
|
|
169
|
+
message: "Chapter not found for the provided project and identifier.",
|
|
170
|
+
details: {
|
|
171
|
+
project_id: projectId,
|
|
172
|
+
chapter: chapterNumber,
|
|
173
|
+
requested_chapters: normalizedChapterNumbers,
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (!seenChapterIds.has(resolved.chapter.chapter_id)) {
|
|
180
|
+
chapters.push(resolved.chapter);
|
|
181
|
+
seenChapterIds.add(resolved.chapter.chapter_id);
|
|
182
|
+
}
|
|
58
183
|
}
|
|
59
184
|
|
|
60
|
-
return {
|
|
185
|
+
return {
|
|
186
|
+
chapters,
|
|
187
|
+
chapter_numbers: normalizedChapterNumbers,
|
|
188
|
+
};
|
|
61
189
|
}
|
package/src/core/helpers.js
CHANGED
|
@@ -156,12 +156,6 @@ export function resolveBatchTargetScenes(dbHandle, {
|
|
|
156
156
|
if (resolvedChapterFilter.chapter) {
|
|
157
157
|
conditions.push("chapter_id = ?");
|
|
158
158
|
params.push(resolvedChapterFilter.chapter.chapter_id);
|
|
159
|
-
} else if (chapterId !== undefined) {
|
|
160
|
-
conditions.push("chapter_id = ?");
|
|
161
|
-
params.push(chapterId);
|
|
162
|
-
} else if (chapter !== undefined) {
|
|
163
|
-
conditions.push("chapter = ?");
|
|
164
|
-
params.push(chapter);
|
|
165
159
|
}
|
|
166
160
|
if (onlyStale) {
|
|
167
161
|
conditions.push("metadata_stale = 1");
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import {
|
|
2
|
+
resolveValidatedChapterFilter,
|
|
3
|
+
resolveValidatedChapterNumberFilters,
|
|
4
|
+
} from "../core/chapter-resolution.js";
|
|
5
|
+
|
|
1
6
|
const MAX_SORT_VALUE = Number.MAX_SAFE_INTEGER;
|
|
2
7
|
const MAX_SCENE_ID_FILTER_PARAMS = 900;
|
|
3
8
|
const MAX_CHAPTER_FILTER_PARAMS = 900;
|
|
@@ -33,7 +38,7 @@ function sceneSort(a, b) {
|
|
|
33
38
|
const partDiff = normalizeSortNumber(a.part) - normalizeSortNumber(b.part);
|
|
34
39
|
if (partDiff !== 0) return partDiff;
|
|
35
40
|
|
|
36
|
-
const chapterDiff = normalizeSortNumber(a.chapter) - normalizeSortNumber(b.chapter);
|
|
41
|
+
const chapterDiff = normalizeSortNumber(a.canonical_chapter_sort ?? a.chapter) - normalizeSortNumber(b.canonical_chapter_sort ?? b.chapter);
|
|
37
42
|
if (chapterDiff !== 0) return chapterDiff;
|
|
38
43
|
|
|
39
44
|
const timelineDiff = normalizeSortNumber(a.timeline_position) - normalizeSortNumber(b.timeline_position);
|
|
@@ -201,6 +206,40 @@ export function buildReviewBundlePlan(dbHandle, {
|
|
|
201
206
|
throw new ReviewBundlePlanError("NOT_FOUND", `Project '${project_id}' not found.`);
|
|
202
207
|
}
|
|
203
208
|
|
|
209
|
+
const resolvedChapterFilter = (chapter !== undefined || chapter_id !== undefined)
|
|
210
|
+
? resolveValidatedChapterFilter(dbHandle, {
|
|
211
|
+
projectId: project_id,
|
|
212
|
+
chapterNumber: chapter,
|
|
213
|
+
chapterId: chapter_id,
|
|
214
|
+
})
|
|
215
|
+
: { chapter: null };
|
|
216
|
+
if (resolvedChapterFilter.error) {
|
|
217
|
+
throw new ReviewBundlePlanError(
|
|
218
|
+
resolvedChapterFilter.error.code,
|
|
219
|
+
resolvedChapterFilter.error.message,
|
|
220
|
+
{
|
|
221
|
+
...(resolvedChapterFilter.error.details ?? {}),
|
|
222
|
+
project_id,
|
|
223
|
+
chapter: chapter ?? null,
|
|
224
|
+
chapter_id: chapter_id ?? null,
|
|
225
|
+
}
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const resolvedChapterSet = Array.isArray(normalizedChapters)
|
|
230
|
+
? resolveValidatedChapterNumberFilters(dbHandle, {
|
|
231
|
+
projectId: project_id,
|
|
232
|
+
chapterNumbers: normalizedChapters,
|
|
233
|
+
})
|
|
234
|
+
: { chapters: [] };
|
|
235
|
+
if (resolvedChapterSet.error) {
|
|
236
|
+
throw new ReviewBundlePlanError(
|
|
237
|
+
resolvedChapterSet.error.code,
|
|
238
|
+
resolvedChapterSet.error.message,
|
|
239
|
+
resolvedChapterSet.error.details
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
204
243
|
const requestedSceneIds = resolveRequestedSceneIds(dbHandle, project_id, scene_ids);
|
|
205
244
|
const conditions = ["s.project_id = ?"];
|
|
206
245
|
const joins = [];
|
|
@@ -220,24 +259,22 @@ export function buildReviewBundlePlan(dbHandle, {
|
|
|
220
259
|
conditions.push("s.part = ?");
|
|
221
260
|
conditionParams.push(part);
|
|
222
261
|
}
|
|
223
|
-
if (chapter
|
|
224
|
-
conditions.push("s.chapter = ?");
|
|
225
|
-
conditionParams.push(chapter);
|
|
226
|
-
}
|
|
227
|
-
if (chapter_id !== undefined) {
|
|
262
|
+
if (resolvedChapterFilter.chapter) {
|
|
228
263
|
conditions.push("s.chapter_id = ?");
|
|
229
|
-
conditionParams.push(chapter_id);
|
|
264
|
+
conditionParams.push(resolvedChapterFilter.chapter.chapter_id);
|
|
230
265
|
}
|
|
231
|
-
if (
|
|
232
|
-
const placeholders =
|
|
233
|
-
conditions.push(`s.
|
|
234
|
-
conditionParams.push(...
|
|
266
|
+
if (resolvedChapterSet.chapters.length > 0) {
|
|
267
|
+
const placeholders = resolvedChapterSet.chapters.map(() => "?").join(",");
|
|
268
|
+
conditions.push(`s.chapter_id IN (${placeholders})`);
|
|
269
|
+
conditionParams.push(...resolvedChapterSet.chapters.map(row => row.chapter_id));
|
|
235
270
|
}
|
|
236
271
|
|
|
237
272
|
let query = `
|
|
238
273
|
SELECT DISTINCT
|
|
239
274
|
s.scene_id,
|
|
240
275
|
s.project_id,
|
|
276
|
+
s.chapter_id,
|
|
277
|
+
c.sort_index AS canonical_chapter_sort,
|
|
241
278
|
s.title,
|
|
242
279
|
s.part,
|
|
243
280
|
s.chapter,
|
|
@@ -248,6 +285,7 @@ export function buildReviewBundlePlan(dbHandle, {
|
|
|
248
285
|
s.save_the_cat_beat,
|
|
249
286
|
s.metadata_stale
|
|
250
287
|
FROM scenes s
|
|
288
|
+
LEFT JOIN chapters c ON c.project_id = s.project_id AND c.chapter_id = s.chapter_id
|
|
251
289
|
`;
|
|
252
290
|
|
|
253
291
|
if (joins.length > 0) {
|
|
@@ -354,6 +392,10 @@ export function buildReviewBundlePlan(dbHandle, {
|
|
|
354
392
|
...(chapter !== undefined ? { chapter } : {}),
|
|
355
393
|
...(chapter_id !== undefined ? { chapter_id } : {}),
|
|
356
394
|
...(Array.isArray(normalizedChapters) ? { chapters: normalizedChapters } : {}),
|
|
395
|
+
...(resolvedChapterFilter.chapter ? { resolved_chapter_id: resolvedChapterFilter.chapter.chapter_id } : {}),
|
|
396
|
+
...(resolvedChapterSet.chapters.length > 0
|
|
397
|
+
? { resolved_chapter_ids: resolvedChapterSet.chapters.map(row => row.chapter_id) }
|
|
398
|
+
: {}),
|
|
357
399
|
...(tag ? { tag } : {}),
|
|
358
400
|
...(Array.isArray(normalizedSceneIds) ? { scene_ids: normalizedSceneIds } : {}),
|
|
359
401
|
};
|
|
@@ -389,9 +431,11 @@ export function buildReviewBundlePlan(dbHandle, {
|
|
|
389
431
|
ordering: rows.map(row => ({
|
|
390
432
|
scene_id: row.scene_id,
|
|
391
433
|
project_id: row.project_id,
|
|
434
|
+
chapter_id: row.chapter_id,
|
|
392
435
|
title: row.title,
|
|
393
436
|
part: row.part,
|
|
394
437
|
chapter: row.chapter,
|
|
438
|
+
canonical_chapter_sort: row.canonical_chapter_sort,
|
|
395
439
|
timeline_position: row.timeline_position,
|
|
396
440
|
metadata_stale: Number(row.metadata_stale) === 1,
|
|
397
441
|
})),
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { applySceneStructurePatch } from "./structure-inference.js";
|
|
2
|
+
|
|
3
|
+
export function buildSceneChapterAssignmentPlan(syncDir, filePath, meta = {}, { chapter } = {}) {
|
|
4
|
+
if (chapter === undefined) {
|
|
5
|
+
return {
|
|
6
|
+
ok: false,
|
|
7
|
+
error: {
|
|
8
|
+
code: "VALIDATION_ERROR",
|
|
9
|
+
message: "Provide a canonical chapter or null to clear the scene chapter link.",
|
|
10
|
+
},
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const currentStructure = applySceneStructurePatch(syncDir, filePath, meta);
|
|
15
|
+
const pathChapter = currentStructure.chapterStructure.chapter ?? null;
|
|
16
|
+
const pathChapterNumber = currentStructure.derived.chapter ?? null;
|
|
17
|
+
|
|
18
|
+
if (chapter === null) {
|
|
19
|
+
if (pathChapter || pathChapterNumber !== null) {
|
|
20
|
+
return {
|
|
21
|
+
ok: false,
|
|
22
|
+
error: {
|
|
23
|
+
code: "VALIDATION_ERROR",
|
|
24
|
+
message: "chapter_id cannot be cleared for a scene whose file path implies a chapter.",
|
|
25
|
+
details: {
|
|
26
|
+
path_chapter: pathChapter?.chapter_id ?? pathChapterNumber,
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
ok: true,
|
|
34
|
+
meta: applySceneStructurePatch(syncDir, filePath, meta, { chapter: null }).meta,
|
|
35
|
+
assignedChapter: null,
|
|
36
|
+
previousChapterId: meta.chapter_id ?? null,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (pathChapter && pathChapter.chapter_id !== chapter.chapter_id) {
|
|
41
|
+
return {
|
|
42
|
+
ok: false,
|
|
43
|
+
error: {
|
|
44
|
+
code: "VALIDATION_ERROR",
|
|
45
|
+
message: "Cannot assign a scene to a different chapter while its file path implies another canonical chapter.",
|
|
46
|
+
details: {
|
|
47
|
+
requested_chapter_id: chapter.chapter_id,
|
|
48
|
+
requested_chapter: chapter.sort_index,
|
|
49
|
+
path_chapter: pathChapter.chapter_id,
|
|
50
|
+
path_chapter_number: pathChapterNumber,
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!pathChapter && pathChapterNumber !== null && pathChapterNumber !== chapter.sort_index) {
|
|
57
|
+
return {
|
|
58
|
+
ok: false,
|
|
59
|
+
error: {
|
|
60
|
+
code: "VALIDATION_ERROR",
|
|
61
|
+
message: "Cannot assign a scene to a different chapter while its file path implies another compatibility chapter.",
|
|
62
|
+
details: {
|
|
63
|
+
requested_chapter_id: chapter.chapter_id,
|
|
64
|
+
requested_chapter: chapter.sort_index,
|
|
65
|
+
path_chapter: pathChapterNumber,
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
ok: true,
|
|
73
|
+
meta: applySceneStructurePatch(syncDir, filePath, meta, { chapter }).meta,
|
|
74
|
+
assignedChapter: chapter,
|
|
75
|
+
previousChapterId: meta.chapter_id ?? null,
|
|
76
|
+
};
|
|
77
|
+
}
|
package/src/tools/metadata.js
CHANGED
|
@@ -4,6 +4,7 @@ import matter from "gray-matter";
|
|
|
4
4
|
import { readMeta, writeMeta, indexSceneFile, applySceneStructurePatch } from "../sync/sync.js";
|
|
5
5
|
import { validateProjectId, validateUniverseId } from "../sync/importer.js";
|
|
6
6
|
import { resolveValidatedChapterFilter } from "../core/chapter-resolution.js";
|
|
7
|
+
import { buildSceneChapterAssignmentPlan } from "../structure/scene-chapter-assignment.js";
|
|
7
8
|
import {
|
|
8
9
|
persistSceneReferenceLink,
|
|
9
10
|
upsertExplicitReferenceLinkRow,
|
|
@@ -510,6 +511,92 @@ export function registerMetadataTools(s, {
|
|
|
510
511
|
}
|
|
511
512
|
);
|
|
512
513
|
|
|
514
|
+
// ---- assign_scene_to_chapter --------------------------------------------
|
|
515
|
+
s.tool(
|
|
516
|
+
"assign_scene_to_chapter",
|
|
517
|
+
"Assign a scene to a canonical chapter through the explicit structure workflow. Writes chapter_id plus compatibility chapter/chapter_title fields to the scene sidecar and refreshes the index. Pass chapter_id=null to clear an explicit chapter link on an unchaptered scene. Use list_chapters first to choose a valid canonical chapter_id.",
|
|
518
|
+
{
|
|
519
|
+
scene_id: z.string().describe("The scene_id to assign (e.g. 'sc-011-sebastian')."),
|
|
520
|
+
project_id: z.string().describe("Project the scene belongs to (e.g. 'the-lamb')."),
|
|
521
|
+
chapter_id: z.string().nullable().describe("Canonical chapter identifier. Use list_chapters to find valid values. Pass null to clear an explicit chapter link on an unchaptered scene."),
|
|
522
|
+
},
|
|
523
|
+
async ({ scene_id, project_id, chapter_id }) => {
|
|
524
|
+
if (!SYNC_DIR_WRITABLE) {
|
|
525
|
+
return errorResponse("READ_ONLY", "Cannot assign scene to chapter: sync dir is read-only.");
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const projectIdCheck = validateProjectId(project_id);
|
|
529
|
+
if (!projectIdCheck.ok) {
|
|
530
|
+
return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const scene = db.prepare(`
|
|
534
|
+
SELECT scene_id, project_id, chapter_id, file_path
|
|
535
|
+
FROM scenes
|
|
536
|
+
WHERE scene_id = ? AND project_id = ?
|
|
537
|
+
`).get(scene_id, project_id);
|
|
538
|
+
if (!scene) {
|
|
539
|
+
return errorResponse("NOT_FOUND", `Scene '${scene_id}' not found in project '${project_id}'.`);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
let chapter = null;
|
|
543
|
+
if (chapter_id !== null) {
|
|
544
|
+
const resolvedChapterFilter = resolveValidatedChapterFilter(db, {
|
|
545
|
+
projectId: project_id,
|
|
546
|
+
chapterId: chapter_id,
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
if (resolvedChapterFilter.error) {
|
|
550
|
+
return errorResponse(
|
|
551
|
+
resolvedChapterFilter.error.code,
|
|
552
|
+
resolvedChapterFilter.error.message,
|
|
553
|
+
{ project_id, chapter_id }
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
chapter = resolvedChapterFilter.chapter;
|
|
558
|
+
if (!chapter) {
|
|
559
|
+
return errorResponse("NOT_FOUND", "Chapter not found for the provided project and identifier.", {
|
|
560
|
+
project_id,
|
|
561
|
+
chapter_id,
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
try {
|
|
567
|
+
const { meta } = readMeta(scene.file_path, SYNC_DIR, { writable: true });
|
|
568
|
+
const plan = buildSceneChapterAssignmentPlan(SYNC_DIR, scene.file_path, meta, { chapter });
|
|
569
|
+
if (!plan.ok) {
|
|
570
|
+
return errorResponse(plan.error.code, plan.error.message, {
|
|
571
|
+
project_id,
|
|
572
|
+
scene_id,
|
|
573
|
+
chapter_id,
|
|
574
|
+
...(plan.error.details ?? {}),
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
writeMeta(scene.file_path, plan.meta);
|
|
579
|
+
|
|
580
|
+
const { content: prose } = matter(fs.readFileSync(scene.file_path, "utf8"));
|
|
581
|
+
indexSceneFile(db, SYNC_DIR, scene.file_path, plan.meta, prose);
|
|
582
|
+
|
|
583
|
+
return jsonResponse({
|
|
584
|
+
ok: true,
|
|
585
|
+
action: chapter === null ? "cleared" : "assigned",
|
|
586
|
+
scene_id,
|
|
587
|
+
project_id,
|
|
588
|
+
previous_chapter_id: plan.previousChapterId ?? scene.chapter_id ?? null,
|
|
589
|
+
chapter: plan.assignedChapter,
|
|
590
|
+
});
|
|
591
|
+
} catch (err) {
|
|
592
|
+
if (err.code === "ENOENT") {
|
|
593
|
+
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 });
|
|
594
|
+
}
|
|
595
|
+
return errorResponse("IO_ERROR", `Failed to assign scene '${scene_id}' to chapter: ${err.message}`);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
);
|
|
599
|
+
|
|
513
600
|
// ---- update_scene_metadata -----------------------------------------------
|
|
514
601
|
s.tool(
|
|
515
602
|
"update_scene_metadata",
|
|
@@ -27,9 +27,9 @@ export function registerReviewBundleTools(s, {
|
|
|
27
27
|
project_id: z.string().describe("Project ID to scope the review bundle (e.g. 'test-novel')."),
|
|
28
28
|
profile: z.enum(REVIEW_BUNDLE_PROFILES).describe("Bundle profile: outline_discussion, editor_detailed, or beta_reader_personalized."),
|
|
29
29
|
part: z.number().int().optional().describe("Optional part filter."),
|
|
30
|
-
chapter: z.number().int().optional().describe("Optional compatibility chapter
|
|
30
|
+
chapter: z.number().int().optional().describe("Optional compatibility chapter number resolved through canonical chapter identity."),
|
|
31
31
|
chapter_id: z.string().optional().describe("Optional canonical chapter identifier."),
|
|
32
|
-
chapters: z.array(z.number().int()).min(1).optional().describe("Optional chapter-set filter. Use this for one/few specific chapters. Do not combine with chapter or chapter_id."),
|
|
32
|
+
chapters: z.array(z.number().int()).min(1).optional().describe("Optional compatibility chapter-set filter resolved through canonical chapter identities. Use this for one/few specific chapters. Do not combine with chapter or chapter_id."),
|
|
33
33
|
tag: z.string().optional().describe("Optional tag filter (exact match)."),
|
|
34
34
|
scene_ids: z.array(z.string()).optional().describe("Optional explicit scene_id allowlist. Intersects with other filters."),
|
|
35
35
|
strictness: z.enum(REVIEW_BUNDLE_STRICTNESS).optional().describe("Strictness mode: warn (default) or fail."),
|
|
@@ -124,9 +124,9 @@ export function registerReviewBundleTools(s, {
|
|
|
124
124
|
profile: z.enum(REVIEW_BUNDLE_PROFILES).describe("Bundle profile: outline_discussion, editor_detailed, or beta_reader_personalized."),
|
|
125
125
|
output_dir: z.string().describe("Directory path to write bundle artifacts into."),
|
|
126
126
|
part: z.number().int().optional().describe("Optional part filter."),
|
|
127
|
-
chapter: z.number().int().optional().describe("Optional compatibility chapter
|
|
127
|
+
chapter: z.number().int().optional().describe("Optional compatibility chapter number resolved through canonical chapter identity."),
|
|
128
128
|
chapter_id: z.string().optional().describe("Optional canonical chapter identifier."),
|
|
129
|
-
chapters: z.array(z.number().int()).min(1).optional().describe("Optional chapter-set filter. Use this for one/few specific chapters. Do not combine with chapter or chapter_id."),
|
|
129
|
+
chapters: z.array(z.number().int()).min(1).optional().describe("Optional compatibility chapter-set filter resolved through canonical chapter identities. Use this for one/few specific chapters. Do not combine with chapter or chapter_id."),
|
|
130
130
|
tag: z.string().optional().describe("Optional tag filter (exact match)."),
|
|
131
131
|
scene_ids: z.array(z.string()).optional().describe("Optional explicit scene_id allowlist. Intersects with other filters."),
|
|
132
132
|
strictness: z.enum(REVIEW_BUNDLE_STRICTNESS).optional().describe("Strictness mode: warn (default) or fail."),
|
package/src/tools/search.js
CHANGED
|
@@ -131,8 +131,10 @@ export function registerSearchTools(s, {
|
|
|
131
131
|
if (resolvedChapterFilter.chapter) {
|
|
132
132
|
conditions.push(`s.chapter_id = ?`);
|
|
133
133
|
params.push(resolvedChapterFilter.chapter.chapter_id);
|
|
134
|
-
} else if (
|
|
135
|
-
|
|
134
|
+
} else if (chapter != null && !project_id) {
|
|
135
|
+
conditions.push(`s.chapter = ?`);
|
|
136
|
+
params.push(chapter);
|
|
137
|
+
}
|
|
136
138
|
if (pov) { conditions.push(`s.pov = ?`); params.push(pov); }
|
|
137
139
|
|
|
138
140
|
if (joins.length) query += " " + joins.join(" ");
|
package/src/tools/styleguide.js
CHANGED
|
@@ -382,7 +382,7 @@ export function registerStyleguideTools(s, {
|
|
|
382
382
|
project_id: z.string().describe("Project ID to analyze (e.g. 'the-lamb' or 'universe-1/book-1')."),
|
|
383
383
|
scene_ids: z.array(z.string()).optional().describe("Optional scene_id allowlist to analyze."),
|
|
384
384
|
part: z.number().int().optional().describe("Optional part filter."),
|
|
385
|
-
chapter: z.number().int().optional().describe("Optional chapter
|
|
385
|
+
chapter: z.number().int().optional().describe("Optional compatibility chapter number resolved through canonical chapter identity."),
|
|
386
386
|
chapter_id: z.string().optional().describe("Optional canonical chapter identifier."),
|
|
387
387
|
max_scenes: z.number().int().positive().optional().describe("Maximum number of scenes to analyze (default: 50)."),
|
|
388
388
|
min_agreement: z.number().min(0).max(1).optional().describe("Minimum agreement ratio for suggested fields (default: 0.6)."),
|
|
@@ -632,7 +632,7 @@ export function registerStyleguideTools(s, {
|
|
|
632
632
|
project_id: z.string().describe("Project ID to analyze (e.g. 'the-lamb' or 'universe-1/book-1')."),
|
|
633
633
|
scene_ids: z.array(z.string()).optional().describe("Optional scene_id allowlist to analyze."),
|
|
634
634
|
part: z.number().int().optional().describe("Optional part filter."),
|
|
635
|
-
chapter: z.number().int().optional().describe("Optional chapter
|
|
635
|
+
chapter: z.number().int().optional().describe("Optional compatibility chapter number resolved through canonical chapter identity."),
|
|
636
636
|
chapter_id: z.string().optional().describe("Optional canonical chapter identifier."),
|
|
637
637
|
max_scenes: z.number().int().positive().optional().describe("Maximum number of scenes to analyze (default: 50)."),
|
|
638
638
|
min_agreement: z.number().min(0).max(1).optional().describe("Minimum agreement ratio for suggested updates (default: 0.6)."),
|
package/src/tools/sync.js
CHANGED
|
@@ -355,7 +355,7 @@ export function registerSyncTools(s, {
|
|
|
355
355
|
project_id: z.string().describe("Project ID (e.g. 'the-lamb' or 'universe-1/book-1-the-lamb')."),
|
|
356
356
|
scene_ids: z.array(z.string()).optional().describe("Optional allowlist of scene IDs to process before other filters are applied."),
|
|
357
357
|
part: z.number().int().optional().describe("Optional part number filter."),
|
|
358
|
-
chapter: z.number().int().optional().describe("Optional chapter number
|
|
358
|
+
chapter: z.number().int().optional().describe("Optional compatibility chapter number resolved through canonical chapter identity."),
|
|
359
359
|
chapter_id: z.string().optional().describe("Optional canonical chapter identifier."),
|
|
360
360
|
only_stale: z.boolean().optional().describe("If true, only process scenes currently marked metadata_stale."),
|
|
361
361
|
dry_run: z.boolean().optional().describe("If true (default), returns preview results without writing sidecars."),
|
|
@@ -75,6 +75,17 @@ export const WORKFLOW_CATALOGUE = [
|
|
|
75
75
|
{ tool: "suggest_scene_references", note: "Use when low parity is specifically about missing scene-to-reference relationships." },
|
|
76
76
|
],
|
|
77
77
|
},
|
|
78
|
+
{
|
|
79
|
+
id: "structure_assignment",
|
|
80
|
+
label: "Assign a scene to a chapter",
|
|
81
|
+
use_when: "Use when the user wants to move an unchaptered scene into a canonical chapter, repair an explicit scene chapter link, or clear a scene's explicit chapter assignment.",
|
|
82
|
+
steps: [
|
|
83
|
+
{ tool: "find_scenes", note: "Identify the target scene and confirm project_id if the user did not provide both." },
|
|
84
|
+
{ tool: "list_chapters", note: "Choose the canonical chapter_id for the target project before assigning." },
|
|
85
|
+
{ tool: "assign_scene_to_chapter", note: "Use this named structure workflow for chapter assignment or clearing instead of editing chapter fields through generic metadata updates." },
|
|
86
|
+
{ tool: "diagnose_structure", note: "Run when the assignment is part of a drift repair workflow or when folder-derived structure may disagree with the requested link." },
|
|
87
|
+
],
|
|
88
|
+
},
|
|
78
89
|
{
|
|
79
90
|
id: "review_preparation",
|
|
80
91
|
label: "Prepare material for human review",
|