novel-writer-cli 0.3.0 → 0.5.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/README.md +1 -1
- package/agents/chapter-writer.md +43 -14
- package/agents/character-weaver.md +7 -1
- package/agents/plot-architect.md +20 -7
- package/agents/quality-judge.md +199 -20
- package/agents/style-analyzer.md +14 -8
- package/agents/style-refiner.md +10 -3
- package/agents/world-builder.md +8 -1
- package/dist/__tests__/agent-prompts-anti-ai-upgrade.test.js +194 -6
- package/dist/__tests__/agent-prompts-platform-expansion.test.js +33 -0
- package/dist/__tests__/anti-ai-infrastructure.test.js +548 -0
- package/dist/__tests__/anti-ai-templates.test.js +2 -2
- package/dist/__tests__/canon-status-lifecycle.test.js +481 -0
- package/dist/__tests__/commit-gate-decision.test.js +65 -0
- package/dist/__tests__/commit-prototype-pollution.test.js +1 -1
- package/dist/__tests__/excitement-type-annotation.test.js +240 -0
- package/dist/__tests__/excitement-type.test.js +21 -0
- package/dist/__tests__/gate-decision.test.js +62 -15
- package/dist/__tests__/genre-excitement-mapping.test.js +355 -0
- package/dist/__tests__/golden-chapter-gates.test.js +79 -0
- package/dist/__tests__/golden-chapter-mini-planning.test.js +485 -0
- package/dist/__tests__/helpers/quickstart-mini-planning.js +61 -0
- package/dist/__tests__/init.test.js +57 -5
- package/dist/__tests__/instructions-platform-expansion.test.js +125 -0
- package/dist/__tests__/next-step-gate-decision-routing.test.js +98 -0
- package/dist/__tests__/orchestrator-state-write-path.test.js +1 -1
- package/dist/__tests__/platform-profile.test.js +57 -1
- package/dist/__tests__/quickstart-pipeline.test.js +73 -6
- package/dist/__tests__/scoring-weights.test.js +193 -0
- package/dist/__tests__/steps-id.test.js +2 -0
- package/dist/__tests__/validate-quickstart-prereqs.test.js +2 -0
- package/dist/advance.js +27 -2
- package/dist/anti-ai-context.js +535 -0
- package/dist/cli.js +3 -1
- package/dist/commit.js +22 -0
- package/dist/excitement-type.js +12 -0
- package/dist/gate-decision.js +98 -2
- package/dist/golden-chapter-gates.js +143 -0
- package/dist/init.js +76 -7
- package/dist/instructions.js +552 -6
- package/dist/next-step.js +124 -88
- package/dist/platform-profile.js +20 -8
- package/dist/quickstart-mini-planning.js +30 -0
- package/dist/scoring-weights.js +38 -3
- package/dist/steps.js +1 -1
- package/dist/validate.js +293 -214
- package/dist/volume-commit.js +271 -5
- package/dist/volume-planning.js +78 -3
- package/docs/user/README.md +1 -0
- package/docs/user/migration-guide.md +166 -0
- package/docs/user/novel-cli.md +4 -3
- package/docs/user/quick-start.md +354 -57
- package/package.json +1 -1
- package/schemas/platform-profile.schema.json +2 -2
- package/scripts/lint-blacklist.sh +221 -76
- package/scripts/lint-structural.sh +538 -0
- package/skills/continue/SKILL.md +6 -0
- package/skills/continue/references/context-contracts.md +71 -6
- package/skills/continue/references/periodic-maintenance.md +12 -1
- package/skills/novel-writing/references/quality-rubric.md +79 -26
- package/skills/novel-writing/references/style-guide.md +129 -19
- package/skills/start/SKILL.md +23 -3
- package/skills/start/references/vol-planning.md +12 -3
- package/templates/ai-blacklist.json +1024 -246
- package/templates/ai-sentence-patterns.json +167 -0
- package/templates/genre-excitement-map.json +48 -0
- package/templates/genre-golden-standards.json +80 -0
- package/templates/genre-weight-profiles.json +15 -0
- package/templates/golden-chapter-gates.json +230 -0
- package/templates/novel-ask/example.question.json +3 -2
- package/templates/platform-profile.json +141 -1
- package/templates/platforms/fanqie.md +35 -0
- package/templates/platforms/jinjiang.md +35 -0
- package/templates/platforms/qidian.md +35 -0
- package/templates/style-profile-template.json +3 -0
package/dist/volume-commit.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import { rename } from "node:fs/promises";
|
|
1
|
+
import { copyFile, readdir, rename } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { readCheckpoint, writeCheckpoint } from "./checkpoint.js";
|
|
4
4
|
import { NovelCliError } from "./errors.js";
|
|
5
|
-
import { ensureDir, pathExists, removePath } from "./fs-utils.js";
|
|
5
|
+
import { ensureDir, pathExists, readJsonFile, readTextFile, removePath, writeJsonFile, writeTextFile } from "./fs-utils.js";
|
|
6
6
|
import { withWriteLock } from "./lock.js";
|
|
7
|
+
import { QUICKSTART_MINI_PLANNING_RANGE, quickstartMiniPlanningChapters, startsWithQuickstartMiniPlanningSeedSequence } from "./quickstart-mini-planning.js";
|
|
8
|
+
import { isPlainObject } from "./type-guards.js";
|
|
7
9
|
import { validateStep } from "./validate.js";
|
|
8
|
-
import { volumeFinalRelPaths, volumeStagingRelPaths } from "./volume-planning.js";
|
|
10
|
+
import { hasQuickstartMiniPlanningSeedBase, volumeFinalRelPaths, volumeStagingRelPaths } from "./volume-planning.js";
|
|
9
11
|
async function doRenameDir(rootDir, fromRel, toRel) {
|
|
10
12
|
const fromAbs = join(rootDir, fromRel);
|
|
11
13
|
const toAbs = join(rootDir, toRel);
|
|
@@ -42,6 +44,264 @@ function requireCheckpointReady(checkpoint, volume) {
|
|
|
42
44
|
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
45
|
}
|
|
44
46
|
}
|
|
47
|
+
function uniqueStrings(values) {
|
|
48
|
+
const out = [];
|
|
49
|
+
const seen = new Set();
|
|
50
|
+
for (const value of values) {
|
|
51
|
+
if (typeof value !== "string")
|
|
52
|
+
continue;
|
|
53
|
+
const trimmed = value.trim();
|
|
54
|
+
if (trimmed.length === 0 || seen.has(trimmed))
|
|
55
|
+
continue;
|
|
56
|
+
seen.add(trimmed);
|
|
57
|
+
out.push(trimmed);
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
function stableValueKey(value) {
|
|
62
|
+
try {
|
|
63
|
+
return JSON.stringify(value) ?? "undefined";
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return String(value);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function uniqueValues(values) {
|
|
70
|
+
const out = [];
|
|
71
|
+
const seen = new Set();
|
|
72
|
+
for (const value of values) {
|
|
73
|
+
const key = stableValueKey(value);
|
|
74
|
+
if (seen.has(key))
|
|
75
|
+
continue;
|
|
76
|
+
seen.add(key);
|
|
77
|
+
out.push(value);
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
function parseOutline(text) {
|
|
82
|
+
const lines = text.split(/\r?\n/u);
|
|
83
|
+
const chapterHeadingRe = /^###\s*第\s*(\d+)\s*章/u;
|
|
84
|
+
const headings = [];
|
|
85
|
+
for (let index = 0; index < lines.length; index++) {
|
|
86
|
+
const match = chapterHeadingRe.exec(lines[index] ?? "");
|
|
87
|
+
if (!match)
|
|
88
|
+
continue;
|
|
89
|
+
const chapter = Number.parseInt(match[1] ?? "", 10);
|
|
90
|
+
if (!Number.isInteger(chapter) || chapter < 1)
|
|
91
|
+
continue;
|
|
92
|
+
headings.push({ chapter, startLine: index, endLine: lines.length });
|
|
93
|
+
}
|
|
94
|
+
if (headings.length === 0) {
|
|
95
|
+
return { header: text.trimEnd(), blocks: new Map() };
|
|
96
|
+
}
|
|
97
|
+
for (let index = 0; index < headings.length; index++) {
|
|
98
|
+
const current = headings[index];
|
|
99
|
+
const next = headings[index + 1];
|
|
100
|
+
current.endLine = next ? next.startLine : lines.length;
|
|
101
|
+
}
|
|
102
|
+
const blocks = new Map();
|
|
103
|
+
for (const heading of headings) {
|
|
104
|
+
if (blocks.has(heading.chapter)) {
|
|
105
|
+
throw new NovelCliError(`Invalid outline during merge: duplicate chapter block for chapter ${heading.chapter}.`, 2);
|
|
106
|
+
}
|
|
107
|
+
blocks.set(heading.chapter, lines.slice(heading.startLine, heading.endLine).join("\n").trim());
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
header: lines.slice(0, headings[0].startLine).join("\n").trimEnd(),
|
|
111
|
+
blocks
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
function mergeSchedule(existingRaw, incomingRaw) {
|
|
115
|
+
if (!isPlainObject(existingRaw) || !isPlainObject(incomingRaw))
|
|
116
|
+
return incomingRaw;
|
|
117
|
+
const existing = existingRaw;
|
|
118
|
+
const incoming = incomingRaw;
|
|
119
|
+
const merged = { ...existing, ...incoming };
|
|
120
|
+
merged.active_storylines = uniqueStrings([
|
|
121
|
+
...(Array.isArray(existing.active_storylines) ? existing.active_storylines : []),
|
|
122
|
+
...(Array.isArray(incoming.active_storylines) ? incoming.active_storylines : [])
|
|
123
|
+
]);
|
|
124
|
+
if (Array.isArray(existing.convergence_events) || Array.isArray(incoming.convergence_events)) {
|
|
125
|
+
merged.convergence_events = uniqueValues([
|
|
126
|
+
...(Array.isArray(existing.convergence_events) ? existing.convergence_events : []),
|
|
127
|
+
...(Array.isArray(incoming.convergence_events) ? incoming.convergence_events : [])
|
|
128
|
+
]);
|
|
129
|
+
}
|
|
130
|
+
const existingPattern = existing.interleaving_pattern;
|
|
131
|
+
const incomingPattern = incoming.interleaving_pattern;
|
|
132
|
+
if (Array.isArray(existingPattern) || Array.isArray(incomingPattern)) {
|
|
133
|
+
merged.interleaving_pattern = uniqueValues([
|
|
134
|
+
...(Array.isArray(existingPattern) ? existingPattern : []),
|
|
135
|
+
...(Array.isArray(incomingPattern) ? incomingPattern : [])
|
|
136
|
+
]);
|
|
137
|
+
}
|
|
138
|
+
else if (isPlainObject(existingPattern) && isPlainObject(incomingPattern)) {
|
|
139
|
+
merged.interleaving_pattern = { ...existingPattern, ...incomingPattern };
|
|
140
|
+
}
|
|
141
|
+
return merged;
|
|
142
|
+
}
|
|
143
|
+
function mergeForeshadowing(existingRaw, incomingRaw) {
|
|
144
|
+
if (!isPlainObject(existingRaw) || !isPlainObject(incomingRaw))
|
|
145
|
+
return incomingRaw;
|
|
146
|
+
const existing = existingRaw;
|
|
147
|
+
const incoming = incomingRaw;
|
|
148
|
+
const merged = { ...existing, ...incoming };
|
|
149
|
+
const items = new Map();
|
|
150
|
+
const appendItem = (item) => {
|
|
151
|
+
if (!isPlainObject(item))
|
|
152
|
+
return;
|
|
153
|
+
const record = item;
|
|
154
|
+
const id = typeof record.id === "string" ? record.id.trim() : "";
|
|
155
|
+
if (id.length === 0) {
|
|
156
|
+
items.set(`__anon_${items.size}`, record);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const previous = items.get(id);
|
|
160
|
+
if (!previous) {
|
|
161
|
+
items.set(id, record);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const mergedRecord = { ...previous, ...record };
|
|
165
|
+
if (Array.isArray(previous.history) || Array.isArray(record.history)) {
|
|
166
|
+
mergedRecord.history = uniqueValues([
|
|
167
|
+
...(Array.isArray(previous.history) ? previous.history : []),
|
|
168
|
+
...(Array.isArray(record.history) ? record.history : [])
|
|
169
|
+
]);
|
|
170
|
+
}
|
|
171
|
+
items.set(id, mergedRecord);
|
|
172
|
+
};
|
|
173
|
+
for (const item of Array.isArray(existing.items) ? existing.items : [])
|
|
174
|
+
appendItem(item);
|
|
175
|
+
for (const item of Array.isArray(incoming.items) ? incoming.items : [])
|
|
176
|
+
appendItem(item);
|
|
177
|
+
merged.items = Array.from(items.values());
|
|
178
|
+
if (existing.schema_version !== undefined && incoming.schema_version === undefined)
|
|
179
|
+
merged.schema_version = existing.schema_version;
|
|
180
|
+
return merged;
|
|
181
|
+
}
|
|
182
|
+
function mergeNewCharacters(existingRaw, incomingRaw) {
|
|
183
|
+
const existing = Array.isArray(existingRaw) ? existingRaw : [];
|
|
184
|
+
const incoming = Array.isArray(incomingRaw) ? incomingRaw : [];
|
|
185
|
+
const merged = [];
|
|
186
|
+
const seen = new Set();
|
|
187
|
+
for (const item of [...existing, ...incoming]) {
|
|
188
|
+
if (!isPlainObject(item)) {
|
|
189
|
+
merged.push(item);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
const record = item;
|
|
193
|
+
const name = typeof record.name === "string" ? record.name.trim() : "";
|
|
194
|
+
const firstChapter = typeof record.first_chapter === "number" ? record.first_chapter : "?";
|
|
195
|
+
const key = `${name}|${String(firstChapter)}`;
|
|
196
|
+
if (seen.has(key))
|
|
197
|
+
continue;
|
|
198
|
+
seen.add(key);
|
|
199
|
+
merged.push(record);
|
|
200
|
+
}
|
|
201
|
+
return merged;
|
|
202
|
+
}
|
|
203
|
+
async function jsonFilesEquivalent(leftAbs, rightAbs) {
|
|
204
|
+
return stableValueKey(await readJsonFile(leftAbs)) === stableValueKey(await readJsonFile(rightAbs));
|
|
205
|
+
}
|
|
206
|
+
async function mergeVolumePlanIntoExistingFinal(rootDir, volume) {
|
|
207
|
+
const staging = volumeStagingRelPaths(volume);
|
|
208
|
+
const final = volumeFinalRelPaths(volume);
|
|
209
|
+
const existingOutline = await readTextFile(join(rootDir, final.outlineMd));
|
|
210
|
+
const incomingOutline = await readTextFile(join(rootDir, staging.outlineMd));
|
|
211
|
+
const existingParsed = parseOutline(existingOutline);
|
|
212
|
+
const incomingParsed = parseOutline(incomingOutline);
|
|
213
|
+
const existingOutlineChapters = [...existingParsed.blocks.keys()].sort((left, right) => left - right);
|
|
214
|
+
const expectedSeedChapters = quickstartMiniPlanningChapters();
|
|
215
|
+
if (!startsWithQuickstartMiniPlanningSeedSequence(existingOutlineChapters)) {
|
|
216
|
+
throw new NovelCliError(`Refusing to merge into existing volume seed: expected outline to start with chapters ${expectedSeedChapters.join(", ")}, got ${existingOutlineChapters.join(", ") || "(none)"}.`, 2);
|
|
217
|
+
}
|
|
218
|
+
const incomingOutlineChapters = [...incomingParsed.blocks.keys()].sort((left, right) => left - right);
|
|
219
|
+
const invalidIncomingChapter = incomingOutlineChapters.find((chapter) => chapter <= QUICKSTART_MINI_PLANNING_RANGE.end);
|
|
220
|
+
if (invalidIncomingChapter !== undefined) {
|
|
221
|
+
throw new NovelCliError(`Refusing to merge staging outline containing seed chapter ${invalidIncomingChapter}; expected formal plan chapters after ${QUICKSTART_MINI_PLANNING_RANGE.end}.`, 2);
|
|
222
|
+
}
|
|
223
|
+
for (const chapter of existingOutlineChapters) {
|
|
224
|
+
if (chapter <= QUICKSTART_MINI_PLANNING_RANGE.end)
|
|
225
|
+
continue;
|
|
226
|
+
const existingBlock = existingParsed.blocks.get(chapter);
|
|
227
|
+
const incomingBlock = incomingParsed.blocks.get(chapter);
|
|
228
|
+
if (!incomingBlock) {
|
|
229
|
+
throw new NovelCliError(`Refusing to resume merge with unexpected existing outline chapter ${chapter} in ${final.outlineMd}.`, 2);
|
|
230
|
+
}
|
|
231
|
+
if (incomingBlock !== existingBlock) {
|
|
232
|
+
throw new NovelCliError(`Refusing to resume merge with conflicting outline block for chapter ${chapter} in ${final.outlineMd}.`, 2);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
const mergedOutlineBlocks = new Map(existingParsed.blocks);
|
|
236
|
+
for (const [chapter, block] of incomingParsed.blocks.entries()) {
|
|
237
|
+
const existingBlock = mergedOutlineBlocks.get(chapter);
|
|
238
|
+
if (existingBlock && existingBlock !== block) {
|
|
239
|
+
throw new NovelCliError(`Refusing to merge conflicting outline block for chapter ${chapter}.`, 2);
|
|
240
|
+
}
|
|
241
|
+
mergedOutlineBlocks.set(chapter, block);
|
|
242
|
+
}
|
|
243
|
+
const outlineHeader = existingParsed.header.trimEnd().length > 0 ? existingParsed.header : incomingParsed.header;
|
|
244
|
+
const combinedOutline = [outlineHeader, ...[...mergedOutlineBlocks.keys()].sort((left, right) => left - right).map((chapter) => mergedOutlineBlocks.get(chapter))]
|
|
245
|
+
.filter((part) => part.trim().length > 0)
|
|
246
|
+
.join("\n\n");
|
|
247
|
+
const mergedSchedule = mergeSchedule(await readJsonFile(join(rootDir, final.storylineScheduleJson)), await readJsonFile(join(rootDir, staging.storylineScheduleJson)));
|
|
248
|
+
const mergedForeshadowing = mergeForeshadowing(await readJsonFile(join(rootDir, final.foreshadowingJson)), await readJsonFile(join(rootDir, staging.foreshadowingJson)));
|
|
249
|
+
const finalNewCharactersAbs = join(rootDir, final.newCharactersJson);
|
|
250
|
+
const mergedNewCharacters = mergeNewCharacters((await pathExists(finalNewCharactersAbs)) ? await readJsonFile(finalNewCharactersAbs) : [], await readJsonFile(join(rootDir, staging.newCharactersJson)));
|
|
251
|
+
const contractCopies = [];
|
|
252
|
+
const incomingContractNames = new Set();
|
|
253
|
+
const contractEntries = await readdir(join(rootDir, staging.chapterContractsDir), { withFileTypes: true });
|
|
254
|
+
for (const entry of contractEntries) {
|
|
255
|
+
if (entry.name.startsWith("."))
|
|
256
|
+
continue;
|
|
257
|
+
if (!entry.isFile() || !entry.name.endsWith(".json"))
|
|
258
|
+
continue;
|
|
259
|
+
const match = /^chapter-(\d+)\.json$/u.exec(entry.name);
|
|
260
|
+
if (!match) {
|
|
261
|
+
throw new NovelCliError(`Unexpected chapter contract filename during merge: ${staging.chapterContractsDir}/${entry.name}`, 2);
|
|
262
|
+
}
|
|
263
|
+
const chapter = Number.parseInt(match[1] ?? "", 10);
|
|
264
|
+
if (chapter <= QUICKSTART_MINI_PLANNING_RANGE.end) {
|
|
265
|
+
throw new NovelCliError(`Refusing to overwrite existing seed contract during merge: ${entry.name}`, 2);
|
|
266
|
+
}
|
|
267
|
+
incomingContractNames.add(entry.name);
|
|
268
|
+
const sourceAbs = join(rootDir, staging.chapterContractsDir, entry.name);
|
|
269
|
+
const destinationAbs = join(rootDir, final.chapterContractsDir, entry.name);
|
|
270
|
+
if (await pathExists(destinationAbs)) {
|
|
271
|
+
if (!(await jsonFilesEquivalent(sourceAbs, destinationAbs))) {
|
|
272
|
+
throw new NovelCliError(`Refusing to overwrite existing destination during merge: ${final.chapterContractsDir}/${entry.name}`, 2);
|
|
273
|
+
}
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
contractCopies.push({ sourceAbs, destinationAbs });
|
|
277
|
+
}
|
|
278
|
+
const finalContractEntries = await readdir(join(rootDir, final.chapterContractsDir), { withFileTypes: true });
|
|
279
|
+
for (const entry of finalContractEntries) {
|
|
280
|
+
if (entry.name.startsWith("."))
|
|
281
|
+
continue;
|
|
282
|
+
if (!entry.isFile() || !entry.name.endsWith(".json"))
|
|
283
|
+
continue;
|
|
284
|
+
const match = /^chapter-(\d+)\.json$/u.exec(entry.name);
|
|
285
|
+
if (!match) {
|
|
286
|
+
throw new NovelCliError(`Unexpected chapter contract filename in final dir during merge: ${final.chapterContractsDir}/${entry.name}`, 2);
|
|
287
|
+
}
|
|
288
|
+
const chapter = Number.parseInt(match[1] ?? "", 10);
|
|
289
|
+
if (chapter <= QUICKSTART_MINI_PLANNING_RANGE.end)
|
|
290
|
+
continue;
|
|
291
|
+
if (!incomingContractNames.has(entry.name)) {
|
|
292
|
+
throw new NovelCliError(`Refusing to resume merge with unexpected existing destination during merge: ${final.chapterContractsDir}/${entry.name}`, 2);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
await writeTextFile(join(rootDir, final.outlineMd), `${combinedOutline.trimEnd()}\n`);
|
|
296
|
+
await writeJsonFile(join(rootDir, final.storylineScheduleJson), mergedSchedule);
|
|
297
|
+
await writeJsonFile(join(rootDir, final.foreshadowingJson), mergedForeshadowing);
|
|
298
|
+
await writeJsonFile(finalNewCharactersAbs, mergedNewCharacters);
|
|
299
|
+
await ensureDir(join(rootDir, final.chapterContractsDir));
|
|
300
|
+
for (const contractCopy of contractCopies) {
|
|
301
|
+
await copyFile(contractCopy.sourceAbs, contractCopy.destinationAbs);
|
|
302
|
+
}
|
|
303
|
+
await removePath(join(rootDir, staging.dir));
|
|
304
|
+
}
|
|
45
305
|
export async function commitVolume(args) {
|
|
46
306
|
requireVolume(args.volume);
|
|
47
307
|
const plan = [];
|
|
@@ -61,6 +321,7 @@ export async function commitVolume(args) {
|
|
|
61
321
|
const finalAbs = join(args.rootDir, finalDir);
|
|
62
322
|
const stagingExists = await pathExists(stagingAbs);
|
|
63
323
|
const finalExists = await pathExists(finalAbs);
|
|
324
|
+
const canMergeExistingFinal = args.volume === 1 && finalExists && stagingExists && await hasQuickstartMiniPlanningSeedBase(args.rootDir);
|
|
64
325
|
if (finalExists && !stagingExists) {
|
|
65
326
|
const required = join(args.rootDir, volumeFinalRelPaths(args.volume).outlineMd);
|
|
66
327
|
if (!(await pathExists(required))) {
|
|
@@ -68,7 +329,7 @@ export async function commitVolume(args) {
|
|
|
68
329
|
}
|
|
69
330
|
warnings.push(`Volume directory already exists (${finalDir}); treating as already committed and only normalizing checkpoint.`);
|
|
70
331
|
}
|
|
71
|
-
else if (finalExists && stagingExists) {
|
|
332
|
+
else if (finalExists && stagingExists && !canMergeExistingFinal) {
|
|
72
333
|
throw new NovelCliError(`Commit conflict: both staging and final volume directories exist (${staging} and ${finalDir}). Refusing to overwrite; resolve manually.`, 2);
|
|
73
334
|
}
|
|
74
335
|
else if (!stagingExists) {
|
|
@@ -76,7 +337,12 @@ export async function commitVolume(args) {
|
|
|
76
337
|
}
|
|
77
338
|
else {
|
|
78
339
|
await validateStep({ rootDir: args.rootDir, checkpoint, step: { kind: "volume", phase: "validate" } });
|
|
79
|
-
|
|
340
|
+
if (canMergeExistingFinal) {
|
|
341
|
+
await mergeVolumePlanIntoExistingFinal(args.rootDir, args.volume);
|
|
342
|
+
}
|
|
343
|
+
else {
|
|
344
|
+
await doRenameDir(args.rootDir, staging, finalDir);
|
|
345
|
+
}
|
|
80
346
|
}
|
|
81
347
|
const updated = { ...checkpoint };
|
|
82
348
|
updated.orchestrator_state = "WRITING";
|
package/dist/volume-planning.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
1
2
|
import { join } from "node:path";
|
|
2
3
|
import { NovelCliError } from "./errors.js";
|
|
3
|
-
import { pathExists } from "./fs-utils.js";
|
|
4
|
+
import { pathExists, readJsonFile, readTextFile } from "./fs-utils.js";
|
|
5
|
+
import { QUICKSTART_MINI_PLANNING_RANGE as SHARED_QUICKSTART_MINI_PLANNING_RANGE, extractOutlineChapterNumbers, matchesQuickstartMiniPlanningSeedSequence, quickstartMiniPlanningChapters, startsWithQuickstartMiniPlanningSeedSequence } from "./quickstart-mini-planning.js";
|
|
4
6
|
import { formatStepId, pad2, pad3 } from "./steps.js";
|
|
5
7
|
export const CHAPTERS_PER_VOLUME = 30;
|
|
8
|
+
export const QUICKSTART_MINI_PLANNING_RANGE = SHARED_QUICKSTART_MINI_PLANNING_RANGE;
|
|
6
9
|
export function volumeForChapter(chapter) {
|
|
7
10
|
if (!Number.isInteger(chapter) || chapter < 1) {
|
|
8
11
|
throw new NovelCliError(`Invalid chapter: ${String(chapter)} (expected int >= 1).`, 2);
|
|
@@ -44,6 +47,79 @@ export function volumeFinalRelPaths(volume) {
|
|
|
44
47
|
chapterContractJson: (chapter) => `${chapterContractsDir}/chapter-${pad3(chapter)}.json`
|
|
45
48
|
};
|
|
46
49
|
}
|
|
50
|
+
export async function hasQuickstartMiniPlanningSeedBase(rootDir) {
|
|
51
|
+
const final = volumeFinalRelPaths(1);
|
|
52
|
+
const seedChapters = quickstartMiniPlanningChapters();
|
|
53
|
+
const requiredPaths = [
|
|
54
|
+
final.outlineMd,
|
|
55
|
+
final.storylineScheduleJson,
|
|
56
|
+
final.foreshadowingJson,
|
|
57
|
+
final.newCharactersJson,
|
|
58
|
+
final.chapterContractsDir,
|
|
59
|
+
...seedChapters.map((chapter) => final.chapterContractJson(chapter))
|
|
60
|
+
];
|
|
61
|
+
try {
|
|
62
|
+
for (const relPath of requiredPaths) {
|
|
63
|
+
if (!(await pathExists(join(rootDir, relPath))))
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
const outline = await readTextFile(join(rootDir, final.outlineMd));
|
|
67
|
+
if (!startsWithQuickstartMiniPlanningSeedSequence(extractOutlineChapterNumbers(outline)))
|
|
68
|
+
return false;
|
|
69
|
+
await readJsonFile(join(rootDir, final.storylineScheduleJson));
|
|
70
|
+
await readJsonFile(join(rootDir, final.foreshadowingJson));
|
|
71
|
+
await readJsonFile(join(rootDir, final.newCharactersJson));
|
|
72
|
+
for (const chapter of seedChapters) {
|
|
73
|
+
const raw = await readJsonFile(join(rootDir, final.chapterContractJson(chapter)));
|
|
74
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
75
|
+
return false;
|
|
76
|
+
if (raw.chapter !== chapter)
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
export async function hasQuickstartMiniPlanningArtifacts(rootDir) {
|
|
86
|
+
if (!(await hasQuickstartMiniPlanningSeedBase(rootDir)))
|
|
87
|
+
return false;
|
|
88
|
+
const final = volumeFinalRelPaths(1);
|
|
89
|
+
try {
|
|
90
|
+
const outline = await readTextFile(join(rootDir, final.outlineMd));
|
|
91
|
+
if (!matchesQuickstartMiniPlanningSeedSequence(extractOutlineChapterNumbers(outline)))
|
|
92
|
+
return false;
|
|
93
|
+
const visibleContractEntries = (await readdir(join(rootDir, final.chapterContractsDir), { withFileTypes: true }))
|
|
94
|
+
.filter((entry) => !entry.name.startsWith("."));
|
|
95
|
+
if (visibleContractEntries.some((entry) => !entry.isFile()))
|
|
96
|
+
return false;
|
|
97
|
+
const contractFiles = visibleContractEntries.map((entry) => entry.name).sort();
|
|
98
|
+
const expectedContractFiles = quickstartMiniPlanningChapters().map((chapter) => `chapter-${pad3(chapter)}.json`);
|
|
99
|
+
return (contractFiles.length === expectedContractFiles.length
|
|
100
|
+
&& !contractFiles.some((fileName, index) => fileName !== expectedContractFiles[index]));
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
export async function resolveVolumeChapterRange(args) {
|
|
107
|
+
const range = computeVolumeChapterRange({ current_volume: args.current_volume, last_completed_chapter: args.last_completed_chapter });
|
|
108
|
+
if (args.current_volume !== 1
|
|
109
|
+
|| range.start !== QUICKSTART_MINI_PLANNING_RANGE.start
|
|
110
|
+
|| range.end <= QUICKSTART_MINI_PLANNING_RANGE.end) {
|
|
111
|
+
return range;
|
|
112
|
+
}
|
|
113
|
+
if (await hasQuickstartMiniPlanningArtifacts(args.rootDir)) {
|
|
114
|
+
return { start: QUICKSTART_MINI_PLANNING_RANGE.end + 1, end: range.end };
|
|
115
|
+
}
|
|
116
|
+
const stagingExists = await pathExists(join(args.rootDir, volumeStagingRelPaths(1).dir));
|
|
117
|
+
if (!stagingExists)
|
|
118
|
+
return range;
|
|
119
|
+
if (!(await hasQuickstartMiniPlanningSeedBase(args.rootDir)))
|
|
120
|
+
return range;
|
|
121
|
+
return { start: QUICKSTART_MINI_PLANNING_RANGE.end + 1, end: range.end };
|
|
122
|
+
}
|
|
47
123
|
function normalizeVolumePipelineStage(value) {
|
|
48
124
|
if (value === null || value === undefined)
|
|
49
125
|
return null;
|
|
@@ -96,7 +172,7 @@ export async function computeVolumeNextStep(rootDir, checkpoint) {
|
|
|
96
172
|
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
173
|
}
|
|
98
174
|
const volume = checkpoint.current_volume;
|
|
99
|
-
const range =
|
|
175
|
+
const range = await resolveVolumeChapterRange({ rootDir, current_volume: volume, last_completed_chapter: checkpoint.last_completed_chapter });
|
|
100
176
|
const artifacts = await hasAllPlanningArtifacts({ rootDir, volume, range });
|
|
101
177
|
if (stage === null || stage === "outline") {
|
|
102
178
|
return {
|
|
@@ -138,6 +214,5 @@ export async function computeVolumeNextStep(rootDir, checkpoint) {
|
|
|
138
214
|
evidence: artifacts.evidence
|
|
139
215
|
};
|
|
140
216
|
}
|
|
141
|
-
// normalizeVolumePipelineStage() ensures the above is exhaustive.
|
|
142
217
|
throw new NovelCliError(`Unsupported volume_pipeline_stage: ${String(checkpoint.volume_pipeline_stage)}`, 2);
|
|
143
218
|
}
|
package/docs/user/README.md
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
- 快速上手(Skill 入口):[快速起步指南](quick-start.md)
|
|
11
11
|
- CLI 手册(最重要):[`novel` CLI](novel-cli.md)
|
|
12
|
+
- 老项目升级:[M8 迁移指南](migration-guide.md)
|
|
12
13
|
- 常用操作(Skill 入口):[常用操作](ops.md)
|
|
13
14
|
- 规范体系(文件/契约/平台画像):[规范体系](spec-system.md)
|
|
14
15
|
- Guardrails(留存/可读性/命名):[Guardrails](guardrails.md)
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# M8 迁移指南
|
|
2
|
+
|
|
3
|
+
这份指南面向已经在使用项目、现在想理解 M8 增量能力的作者。好消息是:M8 的核心变化都做了向后兼容(backward compatibility),大多数老项目**不需要停机迁移**;但如果你准备开新项目、重开项目,或想启用 M8 新特性,仍然建议把全文通读一遍。
|
|
4
|
+
|
|
5
|
+
> 建议先按下表判断优先级,再跳到对应章节。只想继续写旧项目,可以先看与你当前项目最相关的章节;准备开新项目或重开项目,则一定要看最后的 Quick Start 工作流变更。
|
|
6
|
+
|
|
7
|
+
## 迁移优先级速查
|
|
8
|
+
|
|
9
|
+
| 场景 | 优先看什么 |
|
|
10
|
+
|------|------------|
|
|
11
|
+
| 老项目继续写,不想改流程 | `canon_status`、`excitement_type`、题材特定标准 |
|
|
12
|
+
| 老项目想启用平台化评审 | 平台写作指南、`tomato` → `fanqie` |
|
|
13
|
+
| 新开项目 / 重开项目 | 黄金三章门控 + Quick Start 工作流变更(Step F0 + Step F) |
|
|
14
|
+
|
|
15
|
+
## 1. 正典状态(`canon_status`)迁移
|
|
16
|
+
|
|
17
|
+
### 是否需要操作
|
|
18
|
+
|
|
19
|
+
**默认不需要。** 老项目里没有 `canon_status` 字段的规则和角色,会自动按 `established` 处理。
|
|
20
|
+
|
|
21
|
+
### 如何操作
|
|
22
|
+
|
|
23
|
+
如果你想开始区分“现在就生效”和“只是先铺垫”的设定,可以逐步补字段:
|
|
24
|
+
|
|
25
|
+
- 在 `world/rules.json` 里,为规则补 `canon_status`;
|
|
26
|
+
- 在 `characters/active/*.json` 里,为角色补 `canon_status`;
|
|
27
|
+
- 推荐用法:
|
|
28
|
+
- `established`:从当前章节开始就必须遵守;
|
|
29
|
+
- `planned`:允许进入上下文做铺垫,但暂不作为硬约束;
|
|
30
|
+
- `deprecated`:保留历史记录,但不再参与当前创作。
|
|
31
|
+
|
|
32
|
+
一个实用判断标准是:如果这条规则 / 角色“现在违背了也不能算错”,就先标 `planned`,不要急着标 `established`。
|
|
33
|
+
|
|
34
|
+
### 不操作会怎样
|
|
35
|
+
|
|
36
|
+
**不会出问题。** 系统会把缺失字段当成 `established`,所以老项目行为与 M8 之前保持一致;只是你暂时用不上“计划中设定 / 已废弃设定”的精细语义。
|
|
37
|
+
|
|
38
|
+
## 2. `tomato` → `fanqie` 平台名迁移
|
|
39
|
+
|
|
40
|
+
### 是否需要操作
|
|
41
|
+
|
|
42
|
+
**可选。** 如果你的老项目里仍然写的是 `platform: "tomato"`,功能上依然可用。
|
|
43
|
+
|
|
44
|
+
### 如何操作
|
|
45
|
+
|
|
46
|
+
如果你想把项目里的命名统一到新的规范,可以手动把可见配置改成 `fanqie`:
|
|
47
|
+
|
|
48
|
+
1. 检查 `platform-profile.json` 中的 `platform` 字段;
|
|
49
|
+
2. 如有需要,同步更新 `brief.md` 里的目标平台描述;
|
|
50
|
+
3. 如果 `style-profile.json.platform` 里还保留旧值,也可以一起改成 `fanqie`,方便团队阅读与排障。
|
|
51
|
+
|
|
52
|
+
> 新建项目时,界面只展示 `fanqie(番茄)`,不再展示 `tomato`;但系统内部仍会接受旧别名并自动做 canonical 化处理。
|
|
53
|
+
|
|
54
|
+
### 不操作会怎样
|
|
55
|
+
|
|
56
|
+
**几乎没有功能影响。** `tomato` 会继续作为 `fanqie` 的兼容别名工作,门控和平台规则仍会按番茄平台执行。唯一影响主要是:文档、配置和新项目命名不一致,阅读时略显混乱。
|
|
57
|
+
|
|
58
|
+
## 3. 兴奋点类型(`excitement_type`)迁移
|
|
59
|
+
|
|
60
|
+
### 是否需要操作
|
|
61
|
+
|
|
62
|
+
**默认不需要。** 旧的 L3 章节契约没有 `excitement_type` 时,系统会按 `null` 处理。
|
|
63
|
+
|
|
64
|
+
### 如何操作
|
|
65
|
+
|
|
66
|
+
如果你想让节奏评审更贴近“这一章到底想让读者爽在哪里”,可以从新增或重规划的章节开始补:
|
|
67
|
+
|
|
68
|
+
- 在 L3 契约里使用这些值:`reversal`、`face_slap`、`power_up`、`reveal`、`cliffhanger`、`setup`、`null`;
|
|
69
|
+
- 如果你走的是新 Quick Start 或新的卷规划流程,PlotArchitect 通常会自动写入,不需要你手填;
|
|
70
|
+
- 如果你手动维护旧项目的章节契约,可以只给关键章节补,不必一次性回填全书。
|
|
71
|
+
|
|
72
|
+
### 不操作会怎样
|
|
73
|
+
|
|
74
|
+
**系统仍能正常运行。** 缺失字段会被视为 `null`,QualityJudge 不会做特定爽点落地检查,只会按普通节奏标准评审。这意味着你失去的是“更精准的节奏反馈”,不是基本可用性。
|
|
75
|
+
|
|
76
|
+
## 4. 黄金三章门控(golden chapter gates)迁移
|
|
77
|
+
|
|
78
|
+
### 是否需要操作
|
|
79
|
+
|
|
80
|
+
**对已经写完第 1–3 章的老项目,通常不需要。** 黄金三章门控只针对开局三章。
|
|
81
|
+
|
|
82
|
+
### 如何操作
|
|
83
|
+
|
|
84
|
+
如果你是下面两种情况,可以考虑补齐:
|
|
85
|
+
|
|
86
|
+
- **全新项目 / 准备重开项目**:直接在初始化时选平台,系统会自动生成 `golden-chapter-gates.json`;
|
|
87
|
+
- **老项目还没正式写完前三章**:如果你非常想把黄金门控补进来,最稳妥的方式仍然是备份后以新目录重新初始化;不要默认系统会把一个已在途项目自动改造成新的 Quick Start。
|
|
88
|
+
|
|
89
|
+
如果你的前三章已经定稿,最稳的策略通常不是“硬套回去重判”,而是把 M8 门控用在**下一本书**或**重开的版本**上。
|
|
90
|
+
|
|
91
|
+
### 不操作会怎样
|
|
92
|
+
|
|
93
|
+
**不会影响已完成章节。** 已经过了开局阶段的项目不会因为 M8 新增黄金门控而被回滚;后续普通章节仍按标准质量门控执行。
|
|
94
|
+
|
|
95
|
+
## 5. 平台写作指南(platform writing guide)迁移
|
|
96
|
+
|
|
97
|
+
### 是否需要操作
|
|
98
|
+
|
|
99
|
+
**可选,但很值得。** 如果你想让系统更明确地区分起点 / 番茄 / 晋江的节奏与钩子偏好,就应该补齐平台配置。
|
|
100
|
+
|
|
101
|
+
### 如何操作
|
|
102
|
+
|
|
103
|
+
建议按“先决定平台,再一次性补齐文件”的方式做:
|
|
104
|
+
|
|
105
|
+
1. 确认你的项目主要面向哪个平台:`qidian` / `fanqie` / `jinjiang`;
|
|
106
|
+
2. 显式设置或更新项目绑定:把 `platform-profile.json.platform` 设为你选定的平台;如果项目里已经存在旧值(例如 `tomato`),就在这里统一改成目标值。
|
|
107
|
+
3. 确保项目根目录具备这些文件:
|
|
108
|
+
- `platform-profile.json`
|
|
109
|
+
- `genre-weight-profiles.json`
|
|
110
|
+
- `golden-chapter-gates.json`
|
|
111
|
+
- `platform-writing-guide.md`
|
|
112
|
+
4. 如果老项目还没有这些文件,最稳妥的方法是:
|
|
113
|
+
- 新建一个临时目录执行 `novel init --platform <platform>`;
|
|
114
|
+
- 从新目录拷贝对应平台文件到老项目;
|
|
115
|
+
- 检查后再继续写作。
|
|
116
|
+
5. 补齐后会生效的主要变化是:
|
|
117
|
+
- ChapterWriter 会读取 `platform-writing-guide.md`,按平台偏好的节奏密度、对白比例、钩子策略与情绪回报节奏来写;
|
|
118
|
+
- QualityJudge 会读取 `platform-profile.json.scoring` + `genre-weight-profiles.json`,给出平台化的动态权重评分;
|
|
119
|
+
- 如果当前处于开局三章,`golden-chapter-gates.json` 也会参与黄金门控。
|
|
120
|
+
|
|
121
|
+
> 注意:`platform-profile.json.platform` 与 `scoring.genre_drive_type` 会被视为不可变绑定。老项目如果此前没有平台画像,第一次补齐时要尽量一次选准;不要在同一项目里频繁切平台。
|
|
122
|
+
|
|
123
|
+
### 不操作会怎样
|
|
124
|
+
|
|
125
|
+
**系统会继续使用平台无关(platform-agnostic)的默认评审。** 也就是说,写作仍能继续,但你拿不到平台化的节奏密度、对话比例、钩子策略和动态评分权重,反馈会更“通用”,不够贴平台。
|
|
126
|
+
|
|
127
|
+
## 6. 题材特定标准(genre-specific standards)迁移
|
|
128
|
+
|
|
129
|
+
### 是否需要操作
|
|
130
|
+
|
|
131
|
+
**默认不需要。** 这是增强项,不是基础可用性的前提。
|
|
132
|
+
|
|
133
|
+
### 如何操作
|
|
134
|
+
|
|
135
|
+
如果你想启用题材感知的黄金三章规划与评审,需要同时满足两件事:
|
|
136
|
+
|
|
137
|
+
1. `brief.md` 里的 `- **题材**:` 要填写清楚,并尽量使用系统已覆盖的题材(如玄幻、都市、科幻、历史、悬疑、言情);
|
|
138
|
+
2. 项目根目录中要有:
|
|
139
|
+
- `genre-excitement-map.json`
|
|
140
|
+
- `genre-golden-standards.json`
|
|
141
|
+
|
|
142
|
+
新版本 `novel init` 已经会写入这两个模板文件;老项目如果缺失,直接从模板补齐即可。
|
|
143
|
+
|
|
144
|
+
### 不操作会怎样
|
|
145
|
+
|
|
146
|
+
**系统会退回通用标准。** 没有题材字段、题材无法匹配,或缺少这两个题材模板文件时,Quick Start 与 QualityJudge 会继续工作,只是不会应用玄幻 / 悬疑 / 言情等差异化标准。
|
|
147
|
+
|
|
148
|
+
## 7. Quick Start 工作流变更(Step F0 + Step F)
|
|
149
|
+
|
|
150
|
+
### 是否需要操作
|
|
151
|
+
|
|
152
|
+
**对已经在写的老项目,通常不需要专门迁移;对准备开新项目、重开项目或想完整启用 M8 开局流程的用户,建议了解。**
|
|
153
|
+
|
|
154
|
+
### 如何操作
|
|
155
|
+
|
|
156
|
+
如果你要从 M8 的新开局方式重新开始,建议按下面理解:
|
|
157
|
+
|
|
158
|
+
1. 新 Quick Start 的固定顺序是 `world → characters → style → f0 → trial → results`;
|
|
159
|
+
2. `quickstart:f0` 会先为 `vol-01` 生成第 1–3 章的迷你规划种子,包括 `outline.md`、L3 契约、`storyline-schedule.json` 和 `foreshadowing.json`;
|
|
160
|
+
3. `quickstart:trial` 不再是假定你直接连续写完三章,而是先试写 1 个代表性章节;
|
|
161
|
+
4. `quickstart:results` 会结合这份试写章和黄金三章规划种子,决定你是进入正式卷规划、调整风格,还是重新试写;
|
|
162
|
+
5. 如果你想在老项目上体验这套新流程,最稳妥的方式是新建目录重新初始化,而不是指望旧项目中途自动插入 Step F0。
|
|
163
|
+
|
|
164
|
+
### 不操作会怎样
|
|
165
|
+
|
|
166
|
+
**老项目不会因此失效。** 你仍然可以按现有状态继续写;只是当你看到新文档或新项目多了 Step F0、试写结果决策和黄金三章迷你规划时,如果没读这一节,可能会不知道这些新增步骤是做什么的。
|
package/docs/user/novel-cli.md
CHANGED
|
@@ -59,7 +59,7 @@ node dist/cli.js --help
|
|
|
59
59
|
|
|
60
60
|
```bash
|
|
61
61
|
mkdir my-novel && cd my-novel
|
|
62
|
-
novel init --platform
|
|
62
|
+
novel init --platform fanqie
|
|
63
63
|
```
|
|
64
64
|
|
|
65
65
|
默认会:
|
|
@@ -67,17 +67,18 @@ novel init --platform tomato
|
|
|
67
67
|
- 写入 `.checkpoint.json`
|
|
68
68
|
- 创建 `staging/**` 必要目录
|
|
69
69
|
- 写入若干模板文件(如 `brief.md`、`style-profile.json`、`ai-blacklist.json`、`web-novel-cliche-lint.json`)
|
|
70
|
+
- 若指定 `--platform`,额外写入 `platform-profile.json`、`genre-weight-profiles.json`、`golden-chapter-gates.json`、`platform-writing-guide.md`
|
|
70
71
|
|
|
71
72
|
常用选项:
|
|
72
73
|
|
|
73
74
|
- `--minimal`:只创建 `.checkpoint.json` + `staging/**`(跳过模板文件)
|
|
74
75
|
- `--force`:覆盖已有文件(谨慎使用)
|
|
75
|
-
- `--platform qidian|
|
|
76
|
+
- `--platform qidian|fanqie|jinjiang`:写入 `platform-profile.json`,并同时写入 `genre-weight-profiles.json`、`golden-chapter-gates.json` 与 `platform-writing-guide.md`(兼容旧别名 `tomato`)
|
|
76
77
|
|
|
77
78
|
也可指定目标目录(会在目录不存在时创建):
|
|
78
79
|
|
|
79
80
|
```bash
|
|
80
|
-
novel init --project ./my-novel --platform
|
|
81
|
+
novel init --project ./my-novel --platform fanqie
|
|
81
82
|
```
|
|
82
83
|
|
|
83
84
|
## 最短路径:跑通“一章的确定性编排”
|