novel-writer-cli 0.0.3 → 0.1.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/dist/__tests__/advance-refine-invalidates-eval.test.js +37 -0
- package/dist/__tests__/character-voice.test.js +1 -1
- package/dist/__tests__/gate-decision.test.js +66 -0
- package/dist/__tests__/init.test.js +7 -2
- package/dist/__tests__/narrative-health-injection.test.js +8 -8
- package/dist/__tests__/next-step-gate-decision-routing.test.js +117 -0
- package/dist/__tests__/next-step-prejudge-guardrails.test.js +112 -16
- package/dist/__tests__/next-step-title-fix.test.js +64 -8
- package/dist/__tests__/orchestrator-state-routing.test.js +168 -0
- package/dist/__tests__/orchestrator-state-write-path.test.js +59 -0
- package/dist/__tests__/steps-id.test.js +23 -0
- package/dist/__tests__/volume-pipeline.test.js +227 -0
- package/dist/__tests__/volume-review-pipeline.test.js +112 -0
- package/dist/__tests__/volume-review-storyline-rhythm.test.js +19 -0
- package/dist/advance.js +145 -48
- package/dist/checkpoint.js +71 -12
- package/dist/cli.js +202 -8
- package/dist/commit.js +1 -0
- package/dist/fs-utils.js +18 -3
- package/dist/gate-decision.js +59 -0
- package/dist/init.js +2 -0
- package/dist/instructions.js +322 -24
- package/dist/next-step.js +198 -34
- package/dist/platform-profile.js +3 -0
- package/dist/steps.js +60 -17
- package/dist/validate.js +275 -2
- package/dist/volume-commit.js +101 -0
- package/dist/volume-planning.js +143 -0
- package/dist/volume-review.js +448 -0
- package/docs/user/novel-cli.md +29 -0
- package/package.json +3 -2
- package/schemas/platform-profile.schema.json +5 -0
package/dist/validate.js
CHANGED
|
@@ -7,6 +7,8 @@ import { rejectPathTraversalInput } from "./safe-path.js";
|
|
|
7
7
|
import { chapterRelPaths, formatStepId, titleFixSnapshotRel } from "./steps.js";
|
|
8
8
|
import { assertTitleFixOnlyChangedTitleLine, extractChapterTitleFromMarkdown } from "./title-policy.js";
|
|
9
9
|
import { isPlainObject } from "./type-guards.js";
|
|
10
|
+
import { VOL_REVIEW_RELS } from "./volume-review.js";
|
|
11
|
+
import { computeVolumeChapterRange, volumeFinalRelPaths, volumeForChapter, volumeStagingRelPaths } from "./volume-planning.js";
|
|
10
12
|
function requireFile(exists, relPath) {
|
|
11
13
|
if (!exists)
|
|
12
14
|
throw new NovelCliError(`Missing required file: ${relPath}`, 2);
|
|
@@ -26,9 +28,280 @@ function requireNumberField(obj, field, file) {
|
|
|
26
28
|
export async function validateStep(args) {
|
|
27
29
|
const warnings = [];
|
|
28
30
|
const stepId = formatStepId(args.step);
|
|
29
|
-
if (args.step.kind
|
|
30
|
-
|
|
31
|
+
if (args.step.kind === "review") {
|
|
32
|
+
const qualitySummaryAbs = join(args.rootDir, VOL_REVIEW_RELS.qualitySummary);
|
|
33
|
+
const auditReportAbs = join(args.rootDir, VOL_REVIEW_RELS.auditReport);
|
|
34
|
+
const reviewReportAbs = join(args.rootDir, VOL_REVIEW_RELS.reviewReport);
|
|
35
|
+
const foreshadowAbs = join(args.rootDir, VOL_REVIEW_RELS.foreshadowStatus);
|
|
36
|
+
if (args.step.phase === "collect") {
|
|
37
|
+
requireFile(await pathExists(qualitySummaryAbs), VOL_REVIEW_RELS.qualitySummary);
|
|
38
|
+
const raw = await readJsonFile(qualitySummaryAbs);
|
|
39
|
+
if (!isPlainObject(raw))
|
|
40
|
+
throw new NovelCliError(`Invalid ${VOL_REVIEW_RELS.qualitySummary}: expected JSON object.`, 2);
|
|
41
|
+
if (raw.schema_version !== 1)
|
|
42
|
+
warnings.push(`Unexpected schema_version in ${VOL_REVIEW_RELS.qualitySummary}.`);
|
|
43
|
+
return { ok: true, step: stepId, warnings };
|
|
44
|
+
}
|
|
45
|
+
if (args.step.phase === "audit") {
|
|
46
|
+
requireFile(await pathExists(qualitySummaryAbs), VOL_REVIEW_RELS.qualitySummary);
|
|
47
|
+
requireFile(await pathExists(auditReportAbs), VOL_REVIEW_RELS.auditReport);
|
|
48
|
+
const raw = await readJsonFile(auditReportAbs);
|
|
49
|
+
if (!isPlainObject(raw))
|
|
50
|
+
throw new NovelCliError(`Invalid ${VOL_REVIEW_RELS.auditReport}: expected JSON object.`, 2);
|
|
51
|
+
if (raw.schema_version !== 1)
|
|
52
|
+
warnings.push(`Unexpected schema_version in ${VOL_REVIEW_RELS.auditReport}.`);
|
|
53
|
+
return { ok: true, step: stepId, warnings };
|
|
54
|
+
}
|
|
55
|
+
if (args.step.phase === "report") {
|
|
56
|
+
requireFile(await pathExists(qualitySummaryAbs), VOL_REVIEW_RELS.qualitySummary);
|
|
57
|
+
requireFile(await pathExists(auditReportAbs), VOL_REVIEW_RELS.auditReport);
|
|
58
|
+
requireFile(await pathExists(reviewReportAbs), VOL_REVIEW_RELS.reviewReport);
|
|
59
|
+
const text = await readTextFile(reviewReportAbs);
|
|
60
|
+
if (text.trim().length === 0)
|
|
61
|
+
throw new NovelCliError(`Empty report file: ${VOL_REVIEW_RELS.reviewReport}`, 2);
|
|
62
|
+
return { ok: true, step: stepId, warnings };
|
|
63
|
+
}
|
|
64
|
+
if (args.step.phase === "cleanup") {
|
|
65
|
+
requireFile(await pathExists(foreshadowAbs), VOL_REVIEW_RELS.foreshadowStatus);
|
|
66
|
+
const raw = await readJsonFile(foreshadowAbs);
|
|
67
|
+
if (!isPlainObject(raw))
|
|
68
|
+
throw new NovelCliError(`Invalid ${VOL_REVIEW_RELS.foreshadowStatus}: expected JSON object.`, 2);
|
|
69
|
+
if (raw.schema_version !== 1)
|
|
70
|
+
warnings.push(`Unexpected schema_version in ${VOL_REVIEW_RELS.foreshadowStatus}.`);
|
|
71
|
+
return { ok: true, step: stepId, warnings };
|
|
72
|
+
}
|
|
73
|
+
if (args.step.phase === "transition") {
|
|
74
|
+
requireFile(await pathExists(qualitySummaryAbs), VOL_REVIEW_RELS.qualitySummary);
|
|
75
|
+
requireFile(await pathExists(auditReportAbs), VOL_REVIEW_RELS.auditReport);
|
|
76
|
+
requireFile(await pathExists(reviewReportAbs), VOL_REVIEW_RELS.reviewReport);
|
|
77
|
+
requireFile(await pathExists(foreshadowAbs), VOL_REVIEW_RELS.foreshadowStatus);
|
|
78
|
+
return { ok: true, step: stepId, warnings };
|
|
79
|
+
}
|
|
80
|
+
const _exhaustive = args.step.phase;
|
|
81
|
+
throw new NovelCliError(`Unsupported review phase: ${String(_exhaustive)}`, 2);
|
|
82
|
+
}
|
|
83
|
+
if (args.step.kind === "volume") {
|
|
84
|
+
const volume = args.checkpoint.current_volume;
|
|
85
|
+
const range = computeVolumeChapterRange({ current_volume: volume, last_completed_chapter: args.checkpoint.last_completed_chapter });
|
|
86
|
+
const rels = volumeStagingRelPaths(volume);
|
|
87
|
+
const requireVolumePlanArtifacts = async () => {
|
|
88
|
+
requireFile(await pathExists(join(args.rootDir, rels.outlineMd)), rels.outlineMd);
|
|
89
|
+
requireFile(await pathExists(join(args.rootDir, rels.storylineScheduleJson)), rels.storylineScheduleJson);
|
|
90
|
+
requireFile(await pathExists(join(args.rootDir, rels.foreshadowingJson)), rels.foreshadowingJson);
|
|
91
|
+
requireFile(await pathExists(join(args.rootDir, rels.newCharactersJson)), rels.newCharactersJson);
|
|
92
|
+
for (let ch = range.start; ch <= range.end; ch++) {
|
|
93
|
+
requireFile(await pathExists(join(args.rootDir, rels.chapterContractJson(ch))), rels.chapterContractJson(ch));
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
const outlineAbs = join(args.rootDir, rels.outlineMd);
|
|
97
|
+
const parseOutlineStorylineIds = async () => {
|
|
98
|
+
const text = await readTextFile(outlineAbs);
|
|
99
|
+
if (text.trim().length === 0)
|
|
100
|
+
throw new NovelCliError(`Empty outline file: ${rels.outlineMd}`, 2);
|
|
101
|
+
const lines = text.split(/\r?\n/u);
|
|
102
|
+
const chapters = [];
|
|
103
|
+
const chapterHeadingRe = /^###\s*第\s*(\d+)\s*章/u;
|
|
104
|
+
for (let i = 0; i < lines.length; i++) {
|
|
105
|
+
const m = chapterHeadingRe.exec(lines[i] ?? "");
|
|
106
|
+
if (!m)
|
|
107
|
+
continue;
|
|
108
|
+
const chapter = Number.parseInt(m[1] ?? "", 10);
|
|
109
|
+
if (!Number.isInteger(chapter) || chapter < 1)
|
|
110
|
+
continue;
|
|
111
|
+
chapters.push({ chapter, startLine: i, endLine: lines.length });
|
|
112
|
+
}
|
|
113
|
+
if (chapters.length === 0) {
|
|
114
|
+
throw new NovelCliError(`Invalid outline: could not parse any chapter headings. Expected lines like "### 第 1 章" (spaces are flexible). File: ${rels.outlineMd}`, 2);
|
|
115
|
+
}
|
|
116
|
+
for (let i = 0; i < chapters.length; i++) {
|
|
117
|
+
const cur = chapters[i];
|
|
118
|
+
const next = chapters[i + 1];
|
|
119
|
+
if (next)
|
|
120
|
+
cur.endLine = next.startLine;
|
|
121
|
+
}
|
|
122
|
+
const seen = new Set();
|
|
123
|
+
for (const c of chapters) {
|
|
124
|
+
if (seen.has(c.chapter))
|
|
125
|
+
throw new NovelCliError(`Invalid outline: duplicate chapter block for chapter ${c.chapter} (${rels.outlineMd}).`, 2);
|
|
126
|
+
seen.add(c.chapter);
|
|
127
|
+
}
|
|
128
|
+
for (let ch = range.start; ch <= range.end; ch++) {
|
|
129
|
+
if (!seen.has(ch)) {
|
|
130
|
+
throw new NovelCliError(`Invalid outline: missing chapter block for chapter ${ch} (expected continuous coverage ${range.start}-${range.end}). File: ${rels.outlineMd}`, 2);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const requiredKeys = [
|
|
134
|
+
"Storyline",
|
|
135
|
+
"POV",
|
|
136
|
+
"Location",
|
|
137
|
+
"Conflict",
|
|
138
|
+
"Arc",
|
|
139
|
+
"Foreshadowing",
|
|
140
|
+
"StateChanges",
|
|
141
|
+
"TransitionHint"
|
|
142
|
+
];
|
|
143
|
+
const storylinesByChapter = new Map();
|
|
144
|
+
for (const c of chapters) {
|
|
145
|
+
if (c.chapter < range.start || c.chapter > range.end)
|
|
146
|
+
continue;
|
|
147
|
+
const blockLines = lines.slice(c.startLine, c.endLine);
|
|
148
|
+
const keyFound = new Set();
|
|
149
|
+
let storylineId = null;
|
|
150
|
+
for (const line of blockLines) {
|
|
151
|
+
for (const k of requiredKeys) {
|
|
152
|
+
const prefix = `- **${k}**:`;
|
|
153
|
+
if (line.startsWith(prefix)) {
|
|
154
|
+
keyFound.add(k);
|
|
155
|
+
if (k === "Storyline") {
|
|
156
|
+
const val = line.slice(prefix.length).trim();
|
|
157
|
+
if (val.length > 0)
|
|
158
|
+
storylineId = val;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
for (const k of requiredKeys) {
|
|
164
|
+
if (!keyFound.has(k)) {
|
|
165
|
+
throw new NovelCliError(`Invalid outline: chapter ${c.chapter} block missing required key '${k}' line. File: ${rels.outlineMd}`, 2);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (!storylineId) {
|
|
169
|
+
throw new NovelCliError(`Invalid outline: chapter ${c.chapter} missing non-empty Storyline value. File: ${rels.outlineMd}`, 2);
|
|
170
|
+
}
|
|
171
|
+
storylinesByChapter.set(c.chapter, storylineId);
|
|
172
|
+
}
|
|
173
|
+
return storylinesByChapter;
|
|
174
|
+
};
|
|
175
|
+
const validateNewCharacters = (raw) => {
|
|
176
|
+
if (!Array.isArray(raw))
|
|
177
|
+
throw new NovelCliError(`Invalid ${rels.newCharactersJson}: expected an array.`, 2);
|
|
178
|
+
for (const [idx, item] of raw.entries()) {
|
|
179
|
+
if (!isPlainObject(item))
|
|
180
|
+
throw new NovelCliError(`Invalid ${rels.newCharactersJson}: entry ${idx} must be an object.`, 2);
|
|
181
|
+
const obj = item;
|
|
182
|
+
const name = typeof obj.name === "string" ? obj.name.trim() : "";
|
|
183
|
+
const role = typeof obj.role === "string" ? obj.role.trim() : "";
|
|
184
|
+
const brief = typeof obj.brief === "string" ? obj.brief.trim() : "";
|
|
185
|
+
const firstChapter = typeof obj.first_chapter === "number" && Number.isInteger(obj.first_chapter) ? obj.first_chapter : null;
|
|
186
|
+
if (name.length === 0)
|
|
187
|
+
throw new NovelCliError(`Invalid ${rels.newCharactersJson}: entry ${idx} missing name.`, 2);
|
|
188
|
+
if (brief.length === 0)
|
|
189
|
+
throw new NovelCliError(`Invalid ${rels.newCharactersJson}: entry ${idx} missing brief.`, 2);
|
|
190
|
+
if (firstChapter === null || firstChapter < range.start || firstChapter > range.end) {
|
|
191
|
+
throw new NovelCliError(`Invalid ${rels.newCharactersJson}: entry ${idx} first_chapter=${String(obj.first_chapter)} out of range (${range.start}-${range.end}).`, 2);
|
|
192
|
+
}
|
|
193
|
+
if (role !== "antagonist" && role !== "supporting" && role !== "minor") {
|
|
194
|
+
throw new NovelCliError(`Invalid ${rels.newCharactersJson}: entry ${idx} role must be one of: antagonist, supporting, minor.`, 2);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
const validateSchedule = (raw, storylinesByChapter) => {
|
|
199
|
+
if (!isPlainObject(raw))
|
|
200
|
+
throw new NovelCliError(`Invalid ${rels.storylineScheduleJson}: must be an object.`, 2);
|
|
201
|
+
const obj = raw;
|
|
202
|
+
const active = obj.active_storylines;
|
|
203
|
+
if (!Array.isArray(active))
|
|
204
|
+
throw new NovelCliError(`Invalid ${rels.storylineScheduleJson}: missing active_storylines array.`, 2);
|
|
205
|
+
const activeIds = [];
|
|
206
|
+
for (const [idx, v] of active.entries()) {
|
|
207
|
+
if (typeof v !== "string") {
|
|
208
|
+
warnings.push(`WARN ${rels.storylineScheduleJson}: active_storylines[${idx}] is not a string; ignoring.`);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
const id = v.trim();
|
|
212
|
+
if (id.length > 0)
|
|
213
|
+
activeIds.push(id);
|
|
214
|
+
}
|
|
215
|
+
if (activeIds.length === 0)
|
|
216
|
+
throw new NovelCliError(`Invalid ${rels.storylineScheduleJson}: active_storylines must be non-empty.`, 2);
|
|
217
|
+
if (activeIds.length > 4)
|
|
218
|
+
throw new NovelCliError(`Invalid ${rels.storylineScheduleJson}: active_storylines length must be <= 4.`, 2);
|
|
219
|
+
const activeSet = new Set(activeIds);
|
|
220
|
+
for (const [ch, storylineId] of storylinesByChapter.entries()) {
|
|
221
|
+
if (!activeSet.has(storylineId)) {
|
|
222
|
+
throw new NovelCliError(`Invalid volume plan: outline chapter ${ch} Storyline=${storylineId} not in storyline-schedule.json.active_storylines.`, 2);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
const validateForeshadowingPlan = (raw) => {
|
|
227
|
+
if (!isPlainObject(raw)) {
|
|
228
|
+
throw new NovelCliError(`Invalid ${rels.foreshadowingJson}: expected JSON object with {schema_version, items}.`, 2);
|
|
229
|
+
}
|
|
230
|
+
const obj = raw;
|
|
231
|
+
const schema = obj.schema_version;
|
|
232
|
+
if (schema !== 1)
|
|
233
|
+
warnings.push(`Unexpected schema_version in ${rels.foreshadowingJson}.`);
|
|
234
|
+
const items = obj.items;
|
|
235
|
+
if (!Array.isArray(items))
|
|
236
|
+
throw new NovelCliError(`Invalid ${rels.foreshadowingJson}: items must be an array.`, 2);
|
|
237
|
+
};
|
|
238
|
+
const validateContracts = async (storylinesByChapter) => {
|
|
239
|
+
for (let ch = range.start; ch <= range.end; ch++) {
|
|
240
|
+
const rel = rels.chapterContractJson(ch);
|
|
241
|
+
const raw = await readJsonFile(join(args.rootDir, rel));
|
|
242
|
+
if (!isPlainObject(raw))
|
|
243
|
+
throw new NovelCliError(`Invalid ${rel}: must be an object.`, 2);
|
|
244
|
+
const obj = raw;
|
|
245
|
+
const chapter = requireNumberField(obj, "chapter", rel);
|
|
246
|
+
if (!Number.isInteger(chapter) || chapter !== ch)
|
|
247
|
+
throw new NovelCliError(`Invalid ${rel}: chapter=${chapter} expected ${ch}.`, 2);
|
|
248
|
+
const storylineId = requireStringField(obj, "storyline_id", rel).trim();
|
|
249
|
+
const expectedStorylineId = storylinesByChapter.get(ch) ?? null;
|
|
250
|
+
if (!expectedStorylineId || storylineId !== expectedStorylineId) {
|
|
251
|
+
throw new NovelCliError(`Invalid ${rel}: storyline_id=${storylineId} expected ${expectedStorylineId ?? "(missing in outline)"}.`, 2);
|
|
252
|
+
}
|
|
253
|
+
const objectivesRaw = obj.objectives;
|
|
254
|
+
if (!Array.isArray(objectivesRaw))
|
|
255
|
+
throw new NovelCliError(`Invalid ${rel}: objectives must be an array.`, 2);
|
|
256
|
+
const hasRequired = objectivesRaw.some((it) => isPlainObject(it) && it.required === true);
|
|
257
|
+
if (!hasRequired)
|
|
258
|
+
throw new NovelCliError(`Invalid ${rel}: objectives must include at least one entry with required=true.`, 2);
|
|
259
|
+
// Chain propagation (minimal): prev.postconditions.state_changes keys must exist in current.preconditions.character_states.
|
|
260
|
+
const prevChapter = ch - 1;
|
|
261
|
+
if (prevChapter >= 1) {
|
|
262
|
+
const prevRel = prevChapter >= range.start
|
|
263
|
+
? rels.chapterContractJson(prevChapter)
|
|
264
|
+
: volumeFinalRelPaths(volumeForChapter(prevChapter)).chapterContractJson(prevChapter);
|
|
265
|
+
if (await pathExists(join(args.rootDir, prevRel))) {
|
|
266
|
+
const prevRaw = await readJsonFile(join(args.rootDir, prevRel));
|
|
267
|
+
if (isPlainObject(prevRaw)) {
|
|
268
|
+
const prevObj = prevRaw;
|
|
269
|
+
const post = isPlainObject(prevObj.postconditions) ? prevObj.postconditions : null;
|
|
270
|
+
const stateChanges = post && isPlainObject(post.state_changes) ? post.state_changes : null;
|
|
271
|
+
const pre = isPlainObject(obj.preconditions) ? obj.preconditions : null;
|
|
272
|
+
const characterStates = pre && isPlainObject(pre.character_states) ? pre.character_states : null;
|
|
273
|
+
if (stateChanges && Object.keys(stateChanges).length > 0) {
|
|
274
|
+
if (!characterStates) {
|
|
275
|
+
throw new NovelCliError(`Invalid ${rel}: missing preconditions.character_states required by chain propagation from ${prevRel}.`, 2);
|
|
276
|
+
}
|
|
277
|
+
for (const characterKey of Object.keys(stateChanges)) {
|
|
278
|
+
if (!(characterKey in characterStates)) {
|
|
279
|
+
throw new NovelCliError(`Invalid ${rel}: preconditions.character_states missing '${characterKey}' (required by ${prevRel}.postconditions.state_changes).`, 2);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
if (args.step.phase === "commit") {
|
|
289
|
+
throw new NovelCliError(`Use 'novel commit --volume ${volume}' for commit.`, 2);
|
|
290
|
+
}
|
|
291
|
+
await requireVolumePlanArtifacts();
|
|
292
|
+
const storylinesByChapter = await parseOutlineStorylineIds();
|
|
293
|
+
const scheduleRaw = await readJsonFile(join(args.rootDir, rels.storylineScheduleJson));
|
|
294
|
+
validateSchedule(scheduleRaw, storylinesByChapter);
|
|
295
|
+
// JSON existence + basic schema.
|
|
296
|
+
const foreshadowingRaw = await readJsonFile(join(args.rootDir, rels.foreshadowingJson));
|
|
297
|
+
validateForeshadowingPlan(foreshadowingRaw);
|
|
298
|
+
const newCharsRaw = await readJsonFile(join(args.rootDir, rels.newCharactersJson));
|
|
299
|
+
validateNewCharacters(newCharsRaw);
|
|
300
|
+
await validateContracts(storylinesByChapter);
|
|
301
|
+
return { ok: true, step: stepId, warnings };
|
|
31
302
|
}
|
|
303
|
+
if (args.step.kind !== "chapter")
|
|
304
|
+
throw new NovelCliError(`Unsupported step: ${stepId}`, 2);
|
|
32
305
|
const rel = chapterRelPaths(args.step.chapter);
|
|
33
306
|
if (args.step.stage === "draft") {
|
|
34
307
|
const absChapter = join(args.rootDir, rel.staging.chapterMd);
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { rename } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { readCheckpoint, writeCheckpoint } from "./checkpoint.js";
|
|
4
|
+
import { NovelCliError } from "./errors.js";
|
|
5
|
+
import { ensureDir, pathExists, removePath } from "./fs-utils.js";
|
|
6
|
+
import { withWriteLock } from "./lock.js";
|
|
7
|
+
import { validateStep } from "./validate.js";
|
|
8
|
+
import { volumeFinalRelPaths, volumeStagingRelPaths } from "./volume-planning.js";
|
|
9
|
+
async function doRenameDir(rootDir, fromRel, toRel) {
|
|
10
|
+
const fromAbs = join(rootDir, fromRel);
|
|
11
|
+
const toAbs = join(rootDir, toRel);
|
|
12
|
+
if (await pathExists(toAbs)) {
|
|
13
|
+
throw new NovelCliError(`Refusing to overwrite existing destination: ${toRel}`, 2);
|
|
14
|
+
}
|
|
15
|
+
await ensureDir(join(rootDir, "volumes"));
|
|
16
|
+
try {
|
|
17
|
+
await rename(fromAbs, toAbs);
|
|
18
|
+
}
|
|
19
|
+
catch (err) {
|
|
20
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
21
|
+
throw new NovelCliError(`Failed to move '${fromRel}' to '${toRel}': ${message}`, 2);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function requireVolume(volume) {
|
|
25
|
+
if (!Number.isInteger(volume) || volume < 1) {
|
|
26
|
+
throw new NovelCliError(`Invalid --volume: ${String(volume)} (expected int >= 1).`, 2);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function requireCheckpointReady(checkpoint, volume) {
|
|
30
|
+
if (checkpoint.current_volume !== volume) {
|
|
31
|
+
throw new NovelCliError(`Volume mismatch: checkpoint.current_volume=${checkpoint.current_volume}, but commit requested --volume ${volume}.`, 2);
|
|
32
|
+
}
|
|
33
|
+
if (checkpoint.orchestrator_state !== "VOL_PLANNING") {
|
|
34
|
+
throw new NovelCliError(`Cannot commit volume plan unless orchestrator_state=VOL_PLANNING (got ${checkpoint.orchestrator_state}).`, 2);
|
|
35
|
+
}
|
|
36
|
+
const stage = checkpoint.pipeline_stage ?? null;
|
|
37
|
+
const inflight = typeof checkpoint.inflight_chapter === "number" ? checkpoint.inflight_chapter : null;
|
|
38
|
+
if (!(stage === null || stage === "committed") || inflight !== null) {
|
|
39
|
+
throw new NovelCliError(`Cannot commit volume plan unless chapter pipeline is idle (pipeline_stage=null|committed and inflight_chapter=null). Got pipeline_stage=${stage ?? "null"} inflight_chapter=${inflight ?? "null"}.`, 2);
|
|
40
|
+
}
|
|
41
|
+
if (checkpoint.volume_pipeline_stage !== "commit") {
|
|
42
|
+
throw new NovelCliError(`Cannot commit volume plan unless volume_pipeline_stage=commit (got ${String(checkpoint.volume_pipeline_stage ?? "null")}). Advance volume steps first.`, 2);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export async function commitVolume(args) {
|
|
46
|
+
requireVolume(args.volume);
|
|
47
|
+
const plan = [];
|
|
48
|
+
const warnings = [];
|
|
49
|
+
const staging = volumeStagingRelPaths(args.volume).dir;
|
|
50
|
+
const finalDir = volumeFinalRelPaths(args.volume).dir;
|
|
51
|
+
plan.push(`MOVE ${staging} -> ${finalDir}`);
|
|
52
|
+
plan.push(`CLEAN staging/foreshadowing`);
|
|
53
|
+
plan.push(`UPDATE .checkpoint.json (orchestrator_state=WRITING)`);
|
|
54
|
+
if (args.dryRun) {
|
|
55
|
+
return { plan, warnings };
|
|
56
|
+
}
|
|
57
|
+
await withWriteLock(args.rootDir, {}, async () => {
|
|
58
|
+
const checkpoint = await readCheckpoint(args.rootDir);
|
|
59
|
+
requireCheckpointReady(checkpoint, args.volume);
|
|
60
|
+
const stagingAbs = join(args.rootDir, staging);
|
|
61
|
+
const finalAbs = join(args.rootDir, finalDir);
|
|
62
|
+
const stagingExists = await pathExists(stagingAbs);
|
|
63
|
+
const finalExists = await pathExists(finalAbs);
|
|
64
|
+
if (finalExists && !stagingExists) {
|
|
65
|
+
const required = join(args.rootDir, volumeFinalRelPaths(args.volume).outlineMd);
|
|
66
|
+
if (!(await pathExists(required))) {
|
|
67
|
+
throw new NovelCliError(`Commit recovery refused: final volume directory exists but is missing ${volumeFinalRelPaths(args.volume).outlineMd}. Resolve manually.`, 2);
|
|
68
|
+
}
|
|
69
|
+
warnings.push(`Volume directory already exists (${finalDir}); treating as already committed and only normalizing checkpoint.`);
|
|
70
|
+
}
|
|
71
|
+
else if (finalExists && stagingExists) {
|
|
72
|
+
throw new NovelCliError(`Commit conflict: both staging and final volume directories exist (${staging} and ${finalDir}). Refusing to overwrite; resolve manually.`, 2);
|
|
73
|
+
}
|
|
74
|
+
else if (!stagingExists) {
|
|
75
|
+
throw new NovelCliError(`Missing staging volume directory: ${staging}`, 2);
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
await validateStep({ rootDir: args.rootDir, checkpoint, step: { kind: "volume", phase: "validate" } });
|
|
79
|
+
await doRenameDir(args.rootDir, staging, finalDir);
|
|
80
|
+
}
|
|
81
|
+
const updated = { ...checkpoint };
|
|
82
|
+
updated.orchestrator_state = "WRITING";
|
|
83
|
+
updated.volume_pipeline_stage = null;
|
|
84
|
+
updated.last_committed_volume = args.volume;
|
|
85
|
+
updated.pipeline_stage = "committed";
|
|
86
|
+
updated.inflight_chapter = null;
|
|
87
|
+
updated.revision_count = 0;
|
|
88
|
+
updated.hook_fix_count = 0;
|
|
89
|
+
updated.title_fix_count = 0;
|
|
90
|
+
updated.last_checkpoint_time = new Date().toISOString();
|
|
91
|
+
await writeCheckpoint(args.rootDir, updated);
|
|
92
|
+
try {
|
|
93
|
+
await removePath(join(args.rootDir, "staging/foreshadowing"));
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
97
|
+
warnings.push(`Failed to clean staging/foreshadowing after commit (non-fatal): ${message}`);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
return { plan, warnings };
|
|
101
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { NovelCliError } from "./errors.js";
|
|
3
|
+
import { pathExists } from "./fs-utils.js";
|
|
4
|
+
import { formatStepId, pad2, pad3 } from "./steps.js";
|
|
5
|
+
export const CHAPTERS_PER_VOLUME = 30;
|
|
6
|
+
export function volumeForChapter(chapter) {
|
|
7
|
+
if (!Number.isInteger(chapter) || chapter < 1) {
|
|
8
|
+
throw new NovelCliError(`Invalid chapter: ${String(chapter)} (expected int >= 1).`, 2);
|
|
9
|
+
}
|
|
10
|
+
return Math.ceil(chapter / CHAPTERS_PER_VOLUME);
|
|
11
|
+
}
|
|
12
|
+
export function computeVolumeChapterRange(args) {
|
|
13
|
+
const volume = args.current_volume;
|
|
14
|
+
const planStart = args.last_completed_chapter + 1;
|
|
15
|
+
const planEnd = volume * CHAPTERS_PER_VOLUME;
|
|
16
|
+
if (planStart > planEnd) {
|
|
17
|
+
throw new NovelCliError(`Invalid volume chapter range: plan_start=${planStart} > plan_end=${planEnd}. Fix .checkpoint.json (current_volume=${volume}, last_completed_chapter=${args.last_completed_chapter}).`, 2);
|
|
18
|
+
}
|
|
19
|
+
return { start: planStart, end: planEnd };
|
|
20
|
+
}
|
|
21
|
+
export function volumeStagingRelPaths(volume) {
|
|
22
|
+
const dir = `staging/volumes/vol-${pad2(volume)}`;
|
|
23
|
+
const chapterContractsDir = `${dir}/chapter-contracts`;
|
|
24
|
+
return {
|
|
25
|
+
dir,
|
|
26
|
+
outlineMd: `${dir}/outline.md`,
|
|
27
|
+
storylineScheduleJson: `${dir}/storyline-schedule.json`,
|
|
28
|
+
foreshadowingJson: `${dir}/foreshadowing.json`,
|
|
29
|
+
newCharactersJson: `${dir}/new-characters.json`,
|
|
30
|
+
chapterContractsDir,
|
|
31
|
+
chapterContractJson: (chapter) => `${chapterContractsDir}/chapter-${pad3(chapter)}.json`
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export function volumeFinalRelPaths(volume) {
|
|
35
|
+
const dir = `volumes/vol-${pad2(volume)}`;
|
|
36
|
+
const chapterContractsDir = `${dir}/chapter-contracts`;
|
|
37
|
+
return {
|
|
38
|
+
dir,
|
|
39
|
+
outlineMd: `${dir}/outline.md`,
|
|
40
|
+
storylineScheduleJson: `${dir}/storyline-schedule.json`,
|
|
41
|
+
foreshadowingJson: `${dir}/foreshadowing.json`,
|
|
42
|
+
newCharactersJson: `${dir}/new-characters.json`,
|
|
43
|
+
chapterContractsDir,
|
|
44
|
+
chapterContractJson: (chapter) => `${chapterContractsDir}/chapter-${pad3(chapter)}.json`
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function normalizeVolumePipelineStage(value) {
|
|
48
|
+
if (value === null || value === undefined)
|
|
49
|
+
return null;
|
|
50
|
+
if (value === "outline" || value === "validate" || value === "commit")
|
|
51
|
+
return value;
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
async function hasAllPlanningArtifacts(args) {
|
|
55
|
+
const rels = volumeStagingRelPaths(args.volume);
|
|
56
|
+
const hasOutline = await pathExists(join(args.rootDir, rels.outlineMd));
|
|
57
|
+
const hasSchedule = await pathExists(join(args.rootDir, rels.storylineScheduleJson));
|
|
58
|
+
const hasForeshadowing = await pathExists(join(args.rootDir, rels.foreshadowingJson));
|
|
59
|
+
const hasNewCharacters = await pathExists(join(args.rootDir, rels.newCharactersJson));
|
|
60
|
+
const hasContractsDir = await pathExists(join(args.rootDir, rels.chapterContractsDir));
|
|
61
|
+
let missingContracts = 0;
|
|
62
|
+
const contractPresenceSample = [];
|
|
63
|
+
for (let ch = args.range.start; ch <= args.range.end; ch++) {
|
|
64
|
+
const exists = await pathExists(join(args.rootDir, rels.chapterContractJson(ch)));
|
|
65
|
+
if (!exists)
|
|
66
|
+
missingContracts += 1;
|
|
67
|
+
if (contractPresenceSample.length < 3)
|
|
68
|
+
contractPresenceSample.push({ chapter: ch, exists });
|
|
69
|
+
}
|
|
70
|
+
const ok = hasOutline && hasSchedule && hasForeshadowing && hasNewCharacters && hasContractsDir && missingContracts === 0;
|
|
71
|
+
return {
|
|
72
|
+
ok,
|
|
73
|
+
evidence: {
|
|
74
|
+
volume: args.volume,
|
|
75
|
+
chapter_range: [args.range.start, args.range.end],
|
|
76
|
+
staging: {
|
|
77
|
+
hasOutline,
|
|
78
|
+
hasSchedule,
|
|
79
|
+
hasForeshadowing,
|
|
80
|
+
hasNewCharacters,
|
|
81
|
+
hasContractsDir,
|
|
82
|
+
missingContracts,
|
|
83
|
+
contractPresenceSample
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
export async function computeVolumeNextStep(rootDir, checkpoint) {
|
|
89
|
+
const stage = normalizeVolumePipelineStage(checkpoint.volume_pipeline_stage);
|
|
90
|
+
const stageIdle = checkpoint.pipeline_stage ?? null;
|
|
91
|
+
const inflight = typeof checkpoint.inflight_chapter === "number" ? checkpoint.inflight_chapter : null;
|
|
92
|
+
if ((stageIdle === null || stageIdle === "committed") && inflight !== null) {
|
|
93
|
+
throw new NovelCliError(`Checkpoint inconsistent for VOL_PLANNING: pipeline_stage=${stageIdle ?? "null"} but inflight_chapter=${inflight}. Set inflight_chapter to null.`, 2);
|
|
94
|
+
}
|
|
95
|
+
if (stageIdle !== null && stageIdle !== "committed") {
|
|
96
|
+
throw new NovelCliError(`Checkpoint inconsistent for VOL_PLANNING: pipeline_stage=${stageIdle} (expected null or committed). Finish the chapter pipeline or repair .checkpoint.json.`, 2);
|
|
97
|
+
}
|
|
98
|
+
const volume = checkpoint.current_volume;
|
|
99
|
+
const range = computeVolumeChapterRange({ current_volume: volume, last_completed_chapter: checkpoint.last_completed_chapter });
|
|
100
|
+
const artifacts = await hasAllPlanningArtifacts({ rootDir, volume, range });
|
|
101
|
+
if (stage === null || stage === "outline") {
|
|
102
|
+
return {
|
|
103
|
+
step: formatStepId({ kind: "volume", phase: "outline" }),
|
|
104
|
+
reason: artifacts.ok ? "vol_planning:outline:artifacts_present" : "vol_planning:outline",
|
|
105
|
+
inflight: { chapter: null, pipeline_stage: null },
|
|
106
|
+
evidence: artifacts.evidence
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
if (stage === "validate") {
|
|
110
|
+
if (!artifacts.ok) {
|
|
111
|
+
return {
|
|
112
|
+
step: formatStepId({ kind: "volume", phase: "outline" }),
|
|
113
|
+
reason: "vol_planning:validate:missing_artifacts",
|
|
114
|
+
inflight: { chapter: null, pipeline_stage: null },
|
|
115
|
+
evidence: artifacts.evidence
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
step: formatStepId({ kind: "volume", phase: "validate" }),
|
|
120
|
+
reason: "vol_planning:validate",
|
|
121
|
+
inflight: { chapter: null, pipeline_stage: null },
|
|
122
|
+
evidence: artifacts.evidence
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
if (stage === "commit") {
|
|
126
|
+
if (!artifacts.ok) {
|
|
127
|
+
return {
|
|
128
|
+
step: formatStepId({ kind: "volume", phase: "outline" }),
|
|
129
|
+
reason: "vol_planning:commit:missing_artifacts",
|
|
130
|
+
inflight: { chapter: null, pipeline_stage: null },
|
|
131
|
+
evidence: artifacts.evidence
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
step: formatStepId({ kind: "volume", phase: "commit" }),
|
|
136
|
+
reason: "vol_planning:commit",
|
|
137
|
+
inflight: { chapter: null, pipeline_stage: null },
|
|
138
|
+
evidence: artifacts.evidence
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
// normalizeVolumePipelineStage() ensures the above is exhaustive.
|
|
142
|
+
throw new NovelCliError(`Unsupported volume_pipeline_stage: ${String(checkpoint.volume_pipeline_stage)}`, 2);
|
|
143
|
+
}
|