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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import test from "node:test";
|
|
@@ -45,6 +45,8 @@ test("resolveInitRootDir rejects path traversal", () => {
|
|
|
45
45
|
test("normalizePlatformId accepts valid values", () => {
|
|
46
46
|
assert.equal(normalizePlatformId("qidian"), "qidian");
|
|
47
47
|
assert.equal(normalizePlatformId("tomato"), "tomato");
|
|
48
|
+
assert.equal(normalizePlatformId("fanqie"), "fanqie");
|
|
49
|
+
assert.equal(normalizePlatformId("jinjiang"), "jinjiang");
|
|
48
50
|
});
|
|
49
51
|
test("normalizePlatformId rejects invalid values", () => {
|
|
50
52
|
assert.throws(() => normalizePlatformId("jjwxc"), (err) => err instanceof NovelCliError && /Invalid --platform.*jjwxc/i.test(err.message));
|
|
@@ -56,8 +58,8 @@ test("initProject creates a runnable skeleton with all checkpoint fields", async
|
|
|
56
58
|
try {
|
|
57
59
|
const result = await initProject({ rootDir });
|
|
58
60
|
assert.equal(result.rootDir, rootDir);
|
|
59
|
-
// Exact created set (non-minimal = checkpoint +
|
|
60
|
-
assert.deepEqual(result.created.sort(), [".checkpoint.json", "ai-blacklist.json", "brief.md", "style-profile.json", "web-novel-cliche-lint.json"].sort());
|
|
61
|
+
// Exact created set (non-minimal = checkpoint + 6 base templates)
|
|
62
|
+
assert.deepEqual(result.created.sort(), [".checkpoint.json", "ai-blacklist.json", "brief.md", "genre-excitement-map.json", "genre-golden-standards.json", "style-profile.json", "web-novel-cliche-lint.json"].sort());
|
|
61
63
|
// All staging dirs ensured
|
|
62
64
|
assert.equal(result.ensuredDirs.length, 10);
|
|
63
65
|
assert.ok(result.ensuredDirs.includes("staging/chapters"));
|
|
@@ -96,9 +98,10 @@ test("initProject creates a runnable skeleton with all checkpoint fields", async
|
|
|
96
98
|
await assertDir(join(rootDir, relDir));
|
|
97
99
|
}
|
|
98
100
|
// All template files exist
|
|
99
|
-
for (const relFile of ["brief.md", "style-profile.json", "ai-blacklist.json", "web-novel-cliche-lint.json"]) {
|
|
101
|
+
for (const relFile of ["brief.md", "style-profile.json", "genre-excitement-map.json", "genre-golden-standards.json", "ai-blacklist.json", "web-novel-cliche-lint.json"]) {
|
|
100
102
|
await assertFile(join(rootDir, relFile));
|
|
101
103
|
}
|
|
104
|
+
assert.equal(await statExists(join(rootDir, "golden-chapter-gates.json")), false);
|
|
102
105
|
}
|
|
103
106
|
finally {
|
|
104
107
|
await rm(rootDir, { recursive: true, force: true });
|
|
@@ -152,6 +155,16 @@ test("initProject skips existing template files without --force", async () => {
|
|
|
152
155
|
await rm(rootDir, { recursive: true, force: true });
|
|
153
156
|
}
|
|
154
157
|
});
|
|
158
|
+
test("initProject rejects path collisions when a template target is a directory", async () => {
|
|
159
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-init-dir-collision-"));
|
|
160
|
+
try {
|
|
161
|
+
await mkdir(join(rootDir, "brief.md"));
|
|
162
|
+
await assert.rejects(() => initProject({ rootDir }), (err) => err instanceof NovelCliError && /brief\.md.*not a file/i.test(err.message));
|
|
163
|
+
}
|
|
164
|
+
finally {
|
|
165
|
+
await rm(rootDir, { recursive: true, force: true });
|
|
166
|
+
}
|
|
167
|
+
});
|
|
155
168
|
test("initProject overwrites template files with --force", async () => {
|
|
156
169
|
const rootDir = await mkdtemp(join(tmpdir(), "novel-init-force-tpl-"));
|
|
157
170
|
try {
|
|
@@ -171,14 +184,21 @@ test("initProject overwrites template files with --force", async () => {
|
|
|
171
184
|
test("initProject writes platform-profile.json + genre-weight-profiles.json for --platform tomato", async () => {
|
|
172
185
|
const rootDir = await mkdtemp(join(tmpdir(), "novel-init-platform-tomato-"));
|
|
173
186
|
try {
|
|
174
|
-
const result = await initProject({ rootDir,
|
|
187
|
+
const result = await initProject({ rootDir, platform: "tomato" });
|
|
175
188
|
assert.ok(result.created.includes("platform-profile.json"));
|
|
176
189
|
assert.ok(result.created.includes("genre-weight-profiles.json"));
|
|
190
|
+
assert.ok(result.created.includes("platform-writing-guide.md"));
|
|
191
|
+
assert.ok(result.created.includes("style-profile.json"));
|
|
192
|
+
assert.ok(result.created.includes("golden-chapter-gates.json"));
|
|
177
193
|
const raw = await readJson(join(rootDir, "platform-profile.json"));
|
|
178
194
|
const profile = parsePlatformProfile(raw, "platform-profile.json");
|
|
179
195
|
assert.equal(profile.platform, "tomato");
|
|
180
196
|
assert.ok(typeof profile.created_at === "string" && profile.created_at.length > 0);
|
|
181
197
|
assert.ok(typeof profile.schema_version === "number");
|
|
198
|
+
const styleProfileRaw = await readJson(join(rootDir, "style-profile.json"));
|
|
199
|
+
assert.equal(styleProfileRaw.platform, "tomato");
|
|
200
|
+
const guide = await readFile(join(rootDir, "platform-writing-guide.md"), "utf8");
|
|
201
|
+
assert.match(guide, /番茄平台写作指南/);
|
|
182
202
|
// genre-weight-profiles.json should be a valid JSON object
|
|
183
203
|
const genreRaw = await readJson(join(rootDir, "genre-weight-profiles.json"));
|
|
184
204
|
assert.ok(typeof genreRaw === "object" && genreRaw !== null && !Array.isArray(genreRaw));
|
|
@@ -194,15 +214,44 @@ test("initProject writes platform-profile.json for --platform qidian", async ()
|
|
|
194
214
|
const result = await initProject({ rootDir, minimal: true, platform: "qidian" });
|
|
195
215
|
assert.ok(result.created.includes("platform-profile.json"));
|
|
196
216
|
assert.ok(result.created.includes("genre-weight-profiles.json"));
|
|
217
|
+
assert.ok(result.created.includes("golden-chapter-gates.json"));
|
|
197
218
|
const raw = await readJson(join(rootDir, "platform-profile.json"));
|
|
198
219
|
const profile = parsePlatformProfile(raw, "platform-profile.json");
|
|
199
220
|
assert.equal(profile.platform, "qidian");
|
|
200
221
|
assert.ok(typeof profile.created_at === "string" && profile.created_at.length > 0);
|
|
222
|
+
await assertFile(join(rootDir, "golden-chapter-gates.json"));
|
|
201
223
|
}
|
|
202
224
|
finally {
|
|
203
225
|
await rm(rootDir, { recursive: true, force: true });
|
|
204
226
|
}
|
|
205
227
|
});
|
|
228
|
+
test("initProject writes fanqie and jinjiang platform artifacts with populated style profile", async () => {
|
|
229
|
+
const fanqieRoot = await mkdtemp(join(tmpdir(), "novel-init-platform-fanqie-"));
|
|
230
|
+
const jinjiangRoot = await mkdtemp(join(tmpdir(), "novel-init-platform-jinjiang-"));
|
|
231
|
+
try {
|
|
232
|
+
await initProject({ rootDir: fanqieRoot, platform: "fanqie" });
|
|
233
|
+
await initProject({ rootDir: jinjiangRoot, platform: "jinjiang" });
|
|
234
|
+
const fanqieStyle = await readJson(join(fanqieRoot, "style-profile.json"));
|
|
235
|
+
const jinjiangStyle = await readJson(join(jinjiangRoot, "style-profile.json"));
|
|
236
|
+
assert.equal(fanqieStyle.platform, "fanqie");
|
|
237
|
+
assert.equal(jinjiangStyle.platform, "jinjiang");
|
|
238
|
+
const fanqieProfile = parsePlatformProfile(await readJson(join(fanqieRoot, "platform-profile.json")), "platform-profile.json");
|
|
239
|
+
const jinjiangProfile = parsePlatformProfile(await readJson(join(jinjiangRoot, "platform-profile.json")), "platform-profile.json");
|
|
240
|
+
assert.equal(fanqieProfile.platform, "fanqie");
|
|
241
|
+
assert.equal(jinjiangProfile.platform, "jinjiang");
|
|
242
|
+
assert.equal(jinjiangProfile.word_count.target_min, 2000);
|
|
243
|
+
assert.equal(jinjiangProfile.word_count.target_max, 3000);
|
|
244
|
+
assert.equal(jinjiangProfile.scoring?.genre_drive_type, "character");
|
|
245
|
+
const fanqieGuide = await readFile(join(fanqieRoot, "platform-writing-guide.md"), "utf8");
|
|
246
|
+
const jinjiangGuide = await readFile(join(jinjiangRoot, "platform-writing-guide.md"), "utf8");
|
|
247
|
+
assert.match(fanqieGuide, /番茄平台写作指南/);
|
|
248
|
+
assert.match(jinjiangGuide, /晋江平台写作指南/);
|
|
249
|
+
}
|
|
250
|
+
finally {
|
|
251
|
+
await rm(fanqieRoot, { recursive: true, force: true });
|
|
252
|
+
await rm(jinjiangRoot, { recursive: true, force: true });
|
|
253
|
+
}
|
|
254
|
+
});
|
|
206
255
|
// ── Minimal mode ────────────────────────────────────────────────────────
|
|
207
256
|
test("initProject minimal mode skips templates", async () => {
|
|
208
257
|
const rootDir = await mkdtemp(join(tmpdir(), "novel-init-minimal-"));
|
|
@@ -215,6 +264,9 @@ test("initProject minimal mode skips templates", async () => {
|
|
|
215
264
|
assert.equal(await statExists(join(rootDir, "brief.md")), false);
|
|
216
265
|
assert.equal(await statExists(join(rootDir, "style-profile.json")), false);
|
|
217
266
|
assert.equal(await statExists(join(rootDir, "ai-blacklist.json")), false);
|
|
267
|
+
assert.equal(await statExists(join(rootDir, "genre-excitement-map.json")), false);
|
|
268
|
+
assert.equal(await statExists(join(rootDir, "genre-golden-standards.json")), false);
|
|
269
|
+
assert.equal(await statExists(join(rootDir, "golden-chapter-gates.json")), false);
|
|
218
270
|
assert.equal(await statExists(join(rootDir, "web-novel-cliche-lint.json")), false);
|
|
219
271
|
}
|
|
220
272
|
finally {
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { buildInstructionPacket } from "../instructions.js";
|
|
8
|
+
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
9
|
+
async function readRepoText(relPath) {
|
|
10
|
+
return readFile(join(repoRoot, relPath), "utf8");
|
|
11
|
+
}
|
|
12
|
+
async function writeText(absPath, contents) {
|
|
13
|
+
await mkdir(dirname(absPath), { recursive: true });
|
|
14
|
+
await writeFile(absPath, contents, "utf8");
|
|
15
|
+
}
|
|
16
|
+
async function writeJson(absPath, payload) {
|
|
17
|
+
await writeText(absPath, `${JSON.stringify(payload, null, 2)}\n`);
|
|
18
|
+
}
|
|
19
|
+
function makeCheckpoint(stage) {
|
|
20
|
+
return {
|
|
21
|
+
last_completed_chapter: 0,
|
|
22
|
+
current_volume: 1,
|
|
23
|
+
orchestrator_state: "WRITING",
|
|
24
|
+
pipeline_stage: stage,
|
|
25
|
+
inflight_chapter: 1,
|
|
26
|
+
revision_count: 0,
|
|
27
|
+
hook_fix_count: 0,
|
|
28
|
+
title_fix_count: 0
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
test("buildInstructionPacket includes platform writing guide for chapter and quickstart writer packets", async () => {
|
|
32
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-platform-guide-packet-"));
|
|
33
|
+
await writeJson(join(rootDir, "platform-profile.json"), {
|
|
34
|
+
schema_version: 1,
|
|
35
|
+
platform: "fanqie",
|
|
36
|
+
created_at: "2026-03-01T00:00:00Z",
|
|
37
|
+
word_count: { target_min: 1500, target_max: 2500, hard_min: 1000, hard_max: 3500 },
|
|
38
|
+
hook_policy: { required: true, min_strength: 3, allowed_types: ["question"], fix_strategy: "hook-fix" },
|
|
39
|
+
info_load: { max_new_entities_per_chapter: 5, max_unknown_entities_per_chapter: 3, max_new_terms_per_1k_words: 5 },
|
|
40
|
+
compliance: { banned_words: [], duplicate_name_policy: "soft" },
|
|
41
|
+
scoring: { genre_drive_type: "plot", weight_profile_id: "plot:v1" }
|
|
42
|
+
});
|
|
43
|
+
await writeText(join(rootDir, "platform-writing-guide.md"), "# 平台指南\n");
|
|
44
|
+
await writeText(join(rootDir, "skills/novel-writing/references/style-guide.md"), "# style guide\n");
|
|
45
|
+
const chapterPacket = (await buildInstructionPacket({
|
|
46
|
+
rootDir,
|
|
47
|
+
checkpoint: makeCheckpoint("committed"),
|
|
48
|
+
step: { kind: "chapter", chapter: 1, stage: "draft" },
|
|
49
|
+
embedMode: null,
|
|
50
|
+
writeManifest: false
|
|
51
|
+
}));
|
|
52
|
+
assert.equal(chapterPacket.packet.manifest.paths.platform_writing_guide, "platform-writing-guide.md");
|
|
53
|
+
assert.equal(chapterPacket.packet.manifest.paths.style_guide, "skills/novel-writing/references/style-guide.md");
|
|
54
|
+
const quickstartPacket = (await buildInstructionPacket({
|
|
55
|
+
rootDir,
|
|
56
|
+
checkpoint: {
|
|
57
|
+
last_completed_chapter: 0,
|
|
58
|
+
current_volume: 1,
|
|
59
|
+
orchestrator_state: "QUICK_START",
|
|
60
|
+
pipeline_stage: null,
|
|
61
|
+
inflight_chapter: null
|
|
62
|
+
},
|
|
63
|
+
step: { kind: "quickstart", phase: "trial" },
|
|
64
|
+
embedMode: null,
|
|
65
|
+
writeManifest: false
|
|
66
|
+
}));
|
|
67
|
+
assert.equal(quickstartPacket.packet.manifest.paths.platform_writing_guide, "platform-writing-guide.md");
|
|
68
|
+
assert.equal(quickstartPacket.packet.manifest.paths.style_guide, "skills/novel-writing/references/style-guide.md");
|
|
69
|
+
});
|
|
70
|
+
test("buildInstructionPacket injects platform-aware scoring and golden chapter gates for judge packets", async () => {
|
|
71
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-platform-judge-packet-"));
|
|
72
|
+
await writeJson(join(rootDir, "platform-profile.json"), {
|
|
73
|
+
schema_version: 1,
|
|
74
|
+
platform: "tomato",
|
|
75
|
+
created_at: "2026-03-01T00:00:00Z",
|
|
76
|
+
word_count: { target_min: 1500, target_max: 2500, hard_min: 1000, hard_max: 3500 },
|
|
77
|
+
hook_policy: { required: true, min_strength: 3, allowed_types: ["question"], fix_strategy: "hook-fix" },
|
|
78
|
+
info_load: { max_new_entities_per_chapter: 5, max_unknown_entities_per_chapter: 3, max_new_terms_per_1k_words: 5 },
|
|
79
|
+
compliance: { banned_words: [], duplicate_name_policy: "soft" },
|
|
80
|
+
scoring: { genre_drive_type: "plot", weight_profile_id: "plot:v1" }
|
|
81
|
+
});
|
|
82
|
+
await writeText(join(rootDir, "platform-writing-guide.md"), "# 番茄平台写作指南\n");
|
|
83
|
+
await writeJson(join(rootDir, "genre-weight-profiles.json"), JSON.parse(await readRepoText("templates/genre-weight-profiles.json")));
|
|
84
|
+
await writeJson(join(rootDir, "golden-chapter-gates.json"), JSON.parse(await readRepoText("templates/golden-chapter-gates.json")));
|
|
85
|
+
await writeText(join(rootDir, "staging/chapters/chapter-001.md"), "# 第1章\n\n正文\n");
|
|
86
|
+
const packet = (await buildInstructionPacket({
|
|
87
|
+
rootDir,
|
|
88
|
+
checkpoint: makeCheckpoint("refined"),
|
|
89
|
+
step: { kind: "chapter", chapter: 1, stage: "judge" },
|
|
90
|
+
embedMode: null,
|
|
91
|
+
writeManifest: false
|
|
92
|
+
}));
|
|
93
|
+
assert.equal(packet.packet.manifest.paths.platform_writing_guide, "platform-writing-guide.md");
|
|
94
|
+
assert.equal(packet.packet.manifest.inline.golden_chapter_gates.platform, "fanqie");
|
|
95
|
+
assert.equal(packet.packet.manifest.inline.golden_chapter_gates.chapter, 1);
|
|
96
|
+
assert.equal(packet.packet.manifest.inline.golden_chapter_gates.source, "golden-chapter-gates.json");
|
|
97
|
+
assert.ok(packet.packet.manifest.inline.scoring_weights.weights.hook_strength > 0);
|
|
98
|
+
});
|
|
99
|
+
test("buildInstructionPacket omits golden chapter gates after chapter 3", async () => {
|
|
100
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-platform-judge-no-gates-"));
|
|
101
|
+
await writeJson(join(rootDir, "platform-profile.json"), {
|
|
102
|
+
schema_version: 1,
|
|
103
|
+
platform: "jinjiang",
|
|
104
|
+
created_at: "2026-03-01T00:00:00Z",
|
|
105
|
+
word_count: { target_min: 2000, target_max: 3000, hard_min: 1500, hard_max: 3800 },
|
|
106
|
+
hook_policy: { required: true, min_strength: 3, allowed_types: ["emotional_cliff"], fix_strategy: "hook-fix" },
|
|
107
|
+
info_load: { max_new_entities_per_chapter: 4, max_unknown_entities_per_chapter: 2, max_new_terms_per_1k_words: 4 },
|
|
108
|
+
compliance: { banned_words: [], duplicate_name_policy: "soft" },
|
|
109
|
+
scoring: { genre_drive_type: "character", weight_profile_id: "character:v1" }
|
|
110
|
+
});
|
|
111
|
+
await writeJson(join(rootDir, "genre-weight-profiles.json"), JSON.parse(await readRepoText("templates/genre-weight-profiles.json")));
|
|
112
|
+
await writeJson(join(rootDir, "golden-chapter-gates.json"), JSON.parse(await readRepoText("templates/golden-chapter-gates.json")));
|
|
113
|
+
await writeText(join(rootDir, "staging/chapters/chapter-004.md"), "# 第4章\n\n正文\n");
|
|
114
|
+
const packet = (await buildInstructionPacket({
|
|
115
|
+
rootDir,
|
|
116
|
+
checkpoint: {
|
|
117
|
+
...makeCheckpoint("refined"),
|
|
118
|
+
inflight_chapter: 4
|
|
119
|
+
},
|
|
120
|
+
step: { kind: "chapter", chapter: 4, stage: "judge" },
|
|
121
|
+
embedMode: null,
|
|
122
|
+
writeManifest: false
|
|
123
|
+
}));
|
|
124
|
+
assert.equal(packet.packet.manifest.inline.golden_chapter_gates, undefined);
|
|
125
|
+
});
|
|
@@ -25,6 +25,23 @@ test("computeNextStep routes judged+eval to commit on gate pass", async () => {
|
|
|
25
25
|
assert.equal(next.step, "chapter:001:commit");
|
|
26
26
|
assert.equal(next.reason, "judged:gate:pass");
|
|
27
27
|
});
|
|
28
|
+
test("computeNextStep routes refined+eval to commit only after gate pass", async () => {
|
|
29
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-next-step-refined-gate-pass-"));
|
|
30
|
+
await mkdir(join(rootDir, "staging/chapters"), { recursive: true });
|
|
31
|
+
await writeFile(join(rootDir, "staging/chapters/chapter-001.md"), "chapter text\n", "utf8");
|
|
32
|
+
await mkdir(join(rootDir, "staging/evaluations"), { recursive: true });
|
|
33
|
+
await writeJson(join(rootDir, "staging/evaluations/chapter-001-eval.json"), { chapter: 1, overall: 4.2, recommendation: "pass" });
|
|
34
|
+
const next = await computeNextStep(rootDir, {
|
|
35
|
+
last_completed_chapter: 0,
|
|
36
|
+
current_volume: 1,
|
|
37
|
+
orchestrator_state: "WRITING",
|
|
38
|
+
pipeline_stage: "refined",
|
|
39
|
+
inflight_chapter: 1,
|
|
40
|
+
revision_count: 0
|
|
41
|
+
});
|
|
42
|
+
assert.equal(next.step, "chapter:001:commit");
|
|
43
|
+
assert.equal(next.reason, "refined:gate:pass");
|
|
44
|
+
});
|
|
28
45
|
test("computeNextStep routes judged+eval to refine on gate polish", async () => {
|
|
29
46
|
const rootDir = await mkdtemp(join(tmpdir(), "novel-next-step-gate-polish-"));
|
|
30
47
|
await mkdir(join(rootDir, "staging/chapters"), { recursive: true });
|
|
@@ -59,6 +76,33 @@ test("computeNextStep routes judged+eval to draft on gate revise", async () => {
|
|
|
59
76
|
assert.equal(next.step, "chapter:001:draft");
|
|
60
77
|
assert.equal(next.reason, "judged:gate:revise");
|
|
61
78
|
});
|
|
79
|
+
test("computeNextStep routes refined+eval to draft when golden chapter gates fail", async () => {
|
|
80
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-next-step-refined-golden-gate-fail-"));
|
|
81
|
+
await mkdir(join(rootDir, "staging/chapters"), { recursive: true });
|
|
82
|
+
await writeFile(join(rootDir, "staging/chapters/chapter-001.md"), "chapter text\n", "utf8");
|
|
83
|
+
await mkdir(join(rootDir, "staging/evaluations"), { recursive: true });
|
|
84
|
+
await writeJson(join(rootDir, "staging/evaluations/chapter-001-eval.json"), {
|
|
85
|
+
chapter: 1,
|
|
86
|
+
overall: 4.8,
|
|
87
|
+
recommendation: "pass",
|
|
88
|
+
golden_chapter_gates: {
|
|
89
|
+
activated: true,
|
|
90
|
+
passed: false,
|
|
91
|
+
failed_gate_ids: ["hook_present"],
|
|
92
|
+
checks: [{ id: "hook_present", status: "fail" }]
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
const next = await computeNextStep(rootDir, {
|
|
96
|
+
last_completed_chapter: 0,
|
|
97
|
+
current_volume: 1,
|
|
98
|
+
orchestrator_state: "WRITING",
|
|
99
|
+
pipeline_stage: "refined",
|
|
100
|
+
inflight_chapter: 1,
|
|
101
|
+
revision_count: 0
|
|
102
|
+
});
|
|
103
|
+
assert.equal(next.step, "chapter:001:draft");
|
|
104
|
+
assert.equal(next.reason, "refined:gate:revise");
|
|
105
|
+
});
|
|
62
106
|
test("computeNextStep routes judged+eval to commit on force_passed when revisions exhausted", async () => {
|
|
63
107
|
const rootDir = await mkdtemp(join(tmpdir(), "novel-next-step-gate-force-passed-"));
|
|
64
108
|
await mkdir(join(rootDir, "staging/chapters"), { recursive: true });
|
|
@@ -115,3 +159,57 @@ test("computeNextStep forces revise when eval has high-confidence violations", a
|
|
|
115
159
|
assert.equal(next.step, "chapter:001:draft");
|
|
116
160
|
assert.equal(next.reason, "judged:gate:revise");
|
|
117
161
|
});
|
|
162
|
+
test("computeNextStep forces revise when golden chapter gates fail despite high overall", async () => {
|
|
163
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-next-step-golden-gate-fail-"));
|
|
164
|
+
await mkdir(join(rootDir, "staging/chapters"), { recursive: true });
|
|
165
|
+
await writeFile(join(rootDir, "staging/chapters/chapter-001.md"), "chapter text\n", "utf8");
|
|
166
|
+
await mkdir(join(rootDir, "staging/evaluations"), { recursive: true });
|
|
167
|
+
await writeJson(join(rootDir, "staging/evaluations/chapter-001-eval.json"), {
|
|
168
|
+
chapter: 1,
|
|
169
|
+
overall: 4.8,
|
|
170
|
+
recommendation: "pass",
|
|
171
|
+
golden_chapter_gates: {
|
|
172
|
+
activated: true,
|
|
173
|
+
passed: false,
|
|
174
|
+
failed_gate_ids: ["protagonist_within_200_words"],
|
|
175
|
+
checks: [{ id: "protagonist_within_200_words", status: "fail" }]
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
const next = await computeNextStep(rootDir, {
|
|
179
|
+
last_completed_chapter: 0,
|
|
180
|
+
current_volume: 1,
|
|
181
|
+
orchestrator_state: "WRITING",
|
|
182
|
+
pipeline_stage: "judged",
|
|
183
|
+
inflight_chapter: 1,
|
|
184
|
+
revision_count: 0
|
|
185
|
+
});
|
|
186
|
+
assert.equal(next.step, "chapter:001:draft");
|
|
187
|
+
assert.equal(next.reason, "judged:gate:revise");
|
|
188
|
+
});
|
|
189
|
+
test("computeNextStep routes to review when golden chapter gate failures persist beyond max revisions", async () => {
|
|
190
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-next-step-golden-gate-pause-"));
|
|
191
|
+
await mkdir(join(rootDir, "staging/chapters"), { recursive: true });
|
|
192
|
+
await writeFile(join(rootDir, "staging/chapters/chapter-001.md"), "chapter text\n", "utf8");
|
|
193
|
+
await mkdir(join(rootDir, "staging/evaluations"), { recursive: true });
|
|
194
|
+
await writeJson(join(rootDir, "staging/evaluations/chapter-001-eval.json"), {
|
|
195
|
+
chapter: 1,
|
|
196
|
+
overall: 4.8,
|
|
197
|
+
recommendation: "pass",
|
|
198
|
+
golden_chapter_gates: {
|
|
199
|
+
activated: true,
|
|
200
|
+
passed: false,
|
|
201
|
+
failed_gate_ids: ["hook_present"],
|
|
202
|
+
checks: [{ id: "hook_present", status: "fail" }]
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
const next = await computeNextStep(rootDir, {
|
|
206
|
+
last_completed_chapter: 0,
|
|
207
|
+
current_volume: 1,
|
|
208
|
+
orchestrator_state: "WRITING",
|
|
209
|
+
pipeline_stage: "judged",
|
|
210
|
+
inflight_chapter: 1,
|
|
211
|
+
revision_count: 2
|
|
212
|
+
});
|
|
213
|
+
assert.equal(next.step, "chapter:001:review");
|
|
214
|
+
assert.equal(next.reason, "judged:gate:pause_for_user");
|
|
215
|
+
});
|
|
@@ -43,7 +43,7 @@ test("commitChapter resets orchestrator_state to WRITING", async () => {
|
|
|
43
43
|
await writeText(join(rootDir, "staging/chapters/chapter-001.md"), `# 第1章\n\n(测试)\n`);
|
|
44
44
|
await writeText(join(rootDir, "staging/summaries/chapter-001-summary.md"), `## 第 1 章摘要\n\n- 测试事件\n`);
|
|
45
45
|
await writeJson(join(rootDir, "staging/state/chapter-001-crossref.json"), { schema_version: 1, chapter: 1, entities: [] });
|
|
46
|
-
await writeJson(join(rootDir, "staging/evaluations/chapter-001-eval.json"), { chapter: 1 });
|
|
46
|
+
await writeJson(join(rootDir, "staging/evaluations/chapter-001-eval.json"), { chapter: 1, overall: 4.0, recommendation: "pass" });
|
|
47
47
|
await writeText(join(rootDir, "staging/storylines/main-arc/memory.md"), `- 测试记忆\n`);
|
|
48
48
|
await writeJson(join(rootDir, "staging/state/chapter-001-delta.json"), {
|
|
49
49
|
chapter: 1,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
3
|
import test from "node:test";
|
|
4
|
-
import { parsePlatformProfile } from "../platform-profile.js";
|
|
4
|
+
import { canonicalPlatformId, parsePlatformProfile } from "../platform-profile.js";
|
|
5
5
|
function makeBaseRaw() {
|
|
6
6
|
return {
|
|
7
7
|
schema_version: 1,
|
|
@@ -21,6 +21,18 @@ test("parsePlatformProfile loads legacy profile without retention/readability/na
|
|
|
21
21
|
assert.equal(Object.prototype.hasOwnProperty.call(profile, "readability"), false);
|
|
22
22
|
assert.equal(Object.prototype.hasOwnProperty.call(profile, "naming"), false);
|
|
23
23
|
});
|
|
24
|
+
test("canonicalPlatformId maps tomato to fanqie and preserves canonical ids", () => {
|
|
25
|
+
assert.equal(canonicalPlatformId("tomato"), "fanqie");
|
|
26
|
+
assert.equal(canonicalPlatformId("fanqie"), "fanqie");
|
|
27
|
+
assert.equal(canonicalPlatformId("qidian"), "qidian");
|
|
28
|
+
assert.equal(canonicalPlatformId("jinjiang"), "jinjiang");
|
|
29
|
+
});
|
|
30
|
+
test("parsePlatformProfile accepts fanqie and jinjiang platform ids", () => {
|
|
31
|
+
const fanqie = parsePlatformProfile({ ...makeBaseRaw(), platform: "fanqie" }, "platform-profile.json");
|
|
32
|
+
const jinjiang = parsePlatformProfile({ ...makeBaseRaw(), platform: "jinjiang" }, "platform-profile.json");
|
|
33
|
+
assert.equal(fanqie.platform, "fanqie");
|
|
34
|
+
assert.equal(jinjiang.platform, "jinjiang");
|
|
35
|
+
});
|
|
24
36
|
test("parsePlatformProfile accepts explicit null retention/readability/naming", () => {
|
|
25
37
|
const raw = {
|
|
26
38
|
...makeBaseRaw(),
|
|
@@ -33,6 +45,18 @@ test("parsePlatformProfile accepts explicit null retention/readability/naming",
|
|
|
33
45
|
assert.equal(profile.readability, null);
|
|
34
46
|
assert.equal(profile.naming, null);
|
|
35
47
|
});
|
|
48
|
+
test("parsePlatformProfile accepts fractional max_new_terms_per_1k_words", () => {
|
|
49
|
+
const raw = {
|
|
50
|
+
...makeBaseRaw(),
|
|
51
|
+
info_load: {
|
|
52
|
+
max_new_entities_per_chapter: 0,
|
|
53
|
+
max_unknown_entities_per_chapter: 0,
|
|
54
|
+
max_new_terms_per_1k_words: 2.5
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
const profile = parsePlatformProfile(raw, "platform-profile.json");
|
|
58
|
+
assert.equal(profile.info_load.max_new_terms_per_1k_words, 2.5);
|
|
59
|
+
});
|
|
36
60
|
test("parsePlatformProfile loads extended profile with retention/readability/naming", () => {
|
|
37
61
|
const raw = {
|
|
38
62
|
...makeBaseRaw(),
|
|
@@ -156,6 +180,20 @@ test("parsePlatformProfile rejects retention.title_policy min_chars > max_chars"
|
|
|
156
180
|
};
|
|
157
181
|
assert.throws(() => parsePlatformProfile(raw, "platform-profile.json"), /min_chars.*<=.*max_chars/i);
|
|
158
182
|
});
|
|
183
|
+
test("parsePlatformProfile rejects word_count target_min > target_max", () => {
|
|
184
|
+
const raw = {
|
|
185
|
+
...makeBaseRaw(),
|
|
186
|
+
word_count: { target_min: 3000, target_max: 2000, hard_min: 1500, hard_max: 3500 }
|
|
187
|
+
};
|
|
188
|
+
assert.throws(() => parsePlatformProfile(raw, "platform-profile.json"), /word_count\.target_min.*<=.*word_count\.target_max/i);
|
|
189
|
+
});
|
|
190
|
+
test("parsePlatformProfile rejects word_count hard_min > hard_max", () => {
|
|
191
|
+
const raw = {
|
|
192
|
+
...makeBaseRaw(),
|
|
193
|
+
word_count: { target_min: 2000, target_max: 3000, hard_min: 3600, hard_max: 3500 }
|
|
194
|
+
};
|
|
195
|
+
assert.throws(() => parsePlatformProfile(raw, "platform-profile.json"), /word_count\.hard_min.*<=.*word_count\.hard_max/i);
|
|
196
|
+
});
|
|
159
197
|
test("parsePlatformProfile rejects retention.title_policy min_chars when float", () => {
|
|
160
198
|
const raw = {
|
|
161
199
|
...makeBaseRaw(),
|
|
@@ -272,3 +310,21 @@ test("templates/platform-profile.json defaults parse as valid platform profiles"
|
|
|
272
310
|
assert.ok(profile.naming, `expected defaults.${platform}.naming to be present`);
|
|
273
311
|
}
|
|
274
312
|
});
|
|
313
|
+
test("templates/platform-profile.json keeps fanqie and tomato shared defaults aligned", async () => {
|
|
314
|
+
const raw = JSON.parse(await readFile("templates/platform-profile.json", "utf8"));
|
|
315
|
+
assert.ok(raw.defaults, "expected templates/platform-profile.json to have defaults");
|
|
316
|
+
const fanqie = raw.defaults?.fanqie;
|
|
317
|
+
const tomato = raw.defaults?.tomato;
|
|
318
|
+
assert.ok(fanqie && tomato, "expected fanqie and tomato defaults");
|
|
319
|
+
const sharedSubset = (profile) => ({
|
|
320
|
+
word_count: profile.word_count,
|
|
321
|
+
hook_policy: profile.hook_policy,
|
|
322
|
+
info_load: profile.info_load,
|
|
323
|
+
compliance: profile.compliance,
|
|
324
|
+
scoring: profile.scoring,
|
|
325
|
+
retention: profile.retention,
|
|
326
|
+
readability: profile.readability,
|
|
327
|
+
naming: profile.naming
|
|
328
|
+
});
|
|
329
|
+
assert.deepEqual(sharedSubset(fanqie), sharedSubset(tomato));
|
|
330
|
+
});
|
|
@@ -7,6 +7,7 @@ import { advanceCheckpointForStep } from "../advance.js";
|
|
|
7
7
|
import { readCheckpoint } from "../checkpoint.js";
|
|
8
8
|
import { buildInstructionPacket } from "../instructions.js";
|
|
9
9
|
import { computeNextStep } from "../next-step.js";
|
|
10
|
+
import { writeCommittedMiniPlanning } from "./helpers/quickstart-mini-planning.js";
|
|
10
11
|
async function writeText(absPath, contents) {
|
|
11
12
|
await mkdir(dirname(absPath), { recursive: true });
|
|
12
13
|
await writeFile(absPath, contents, "utf8");
|
|
@@ -101,6 +102,7 @@ test("advance quickstart:trial writes quickstart_phase correctly", async () => {
|
|
|
101
102
|
await writeJson(join(rootDir, "staging/quickstart/rules.json"), { rules: [] });
|
|
102
103
|
await writeJson(join(rootDir, "staging/quickstart/contracts/hero.json"), { id: "hero", display_name: "阿宁", contracts: [] });
|
|
103
104
|
await writeJson(join(rootDir, "staging/quickstart/style-profile.json"), { source_type: "template" });
|
|
105
|
+
await writeCommittedMiniPlanning(rootDir);
|
|
104
106
|
await writeText(join(rootDir, "staging/quickstart/trial-chapter.md"), "# Trial\n\nText\n");
|
|
105
107
|
const updated = await advanceCheckpointForStep({ rootDir, step: { kind: "quickstart", phase: "trial" } });
|
|
106
108
|
assert.equal(updated.orchestrator_state, "QUICK_START");
|
|
@@ -109,6 +111,21 @@ test("advance quickstart:trial writes quickstart_phase correctly", async () => {
|
|
|
109
111
|
assert.equal(checkpoint.orchestrator_state, "QUICK_START");
|
|
110
112
|
assert.equal(checkpoint.quickstart_phase, "trial");
|
|
111
113
|
});
|
|
114
|
+
test("advance quickstart:trial requires committed mini-planning artifacts", async () => {
|
|
115
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-quickstart-trial-requires-f0-"));
|
|
116
|
+
await writeJson(join(rootDir, ".checkpoint.json"), {
|
|
117
|
+
last_completed_chapter: 0,
|
|
118
|
+
current_volume: 1,
|
|
119
|
+
orchestrator_state: "QUICK_START",
|
|
120
|
+
pipeline_stage: null,
|
|
121
|
+
inflight_chapter: null
|
|
122
|
+
});
|
|
123
|
+
await writeJson(join(rootDir, "staging/quickstart/rules.json"), { rules: [] });
|
|
124
|
+
await writeJson(join(rootDir, "staging/quickstart/contracts/hero.json"), { id: "hero", display_name: "阿宁", contracts: [] });
|
|
125
|
+
await writeJson(join(rootDir, "staging/quickstart/style-profile.json"), { source_type: "template" });
|
|
126
|
+
await writeText(join(rootDir, "staging/quickstart/trial-chapter.md"), "# Trial\n\nText\n");
|
|
127
|
+
await assert.rejects(() => advanceCheckpointForStep({ rootDir, step: { kind: "quickstart", phase: "trial" } }), /Missing committed quickstart mini-planning artifacts/);
|
|
128
|
+
});
|
|
112
129
|
test("advance quickstart rejects wrong orchestrator_state", async () => {
|
|
113
130
|
const rootDir = await mkdtemp(join(tmpdir(), "novel-quickstart-advance-wrong-state-"));
|
|
114
131
|
await writeJson(join(rootDir, ".checkpoint.json"), {
|
|
@@ -140,12 +157,19 @@ test("computeNextStep recovers quickstart phase from staging artifacts", async (
|
|
|
140
157
|
await writeJson(join(rootDir, "staging/quickstart/contracts/hero.json"), { id: "hero", display_name: "阿宁", contracts: [] });
|
|
141
158
|
next = await computeNextStep(rootDir, await readCheckpoint(rootDir));
|
|
142
159
|
assert.equal(next.step, "quickstart:style");
|
|
143
|
-
// style profile present →
|
|
160
|
+
// style profile present → f0
|
|
144
161
|
await writeJson(join(rootDir, "staging/quickstart/style-profile.json"), { source_type: "template" });
|
|
145
162
|
next = await computeNextStep(rootDir, await readCheckpoint(rootDir));
|
|
163
|
+
assert.equal(next.step, "quickstart:f0");
|
|
164
|
+
// committed mini-planning artifacts present → trial
|
|
165
|
+
await writeCommittedMiniPlanning(rootDir);
|
|
166
|
+
next = await computeNextStep(rootDir, await readCheckpoint(rootDir));
|
|
146
167
|
assert.equal(next.step, "quickstart:trial");
|
|
147
168
|
// trial chapter present → results
|
|
148
|
-
await writeText(join(rootDir, "staging/quickstart/trial-chapter.md"), `#
|
|
169
|
+
await writeText(join(rootDir, "staging/quickstart/trial-chapter.md"), `# 试写章
|
|
170
|
+
|
|
171
|
+
(测试)
|
|
172
|
+
`);
|
|
149
173
|
next = await computeNextStep(rootDir, await readCheckpoint(rootDir));
|
|
150
174
|
assert.equal(next.step, "quickstart:results");
|
|
151
175
|
assert.equal(next.reason, "quickstart:results");
|
|
@@ -202,7 +226,7 @@ test("computeNextStep allows redoing style phase when style profile is missing",
|
|
|
202
226
|
assert.equal(next.step, "quickstart:style");
|
|
203
227
|
assert.equal(next.reason, "quickstart:style");
|
|
204
228
|
});
|
|
205
|
-
test("computeNextStep
|
|
229
|
+
test("computeNextStep falls back to f0 when trial phase is missing committed mini-planning artifacts", async () => {
|
|
206
230
|
const rootDir = await mkdtemp(join(tmpdir(), "novel-quickstart-recover-trial-"));
|
|
207
231
|
await writeJson(join(rootDir, ".checkpoint.json"), {
|
|
208
232
|
last_completed_chapter: 0,
|
|
@@ -216,10 +240,12 @@ test("computeNextStep allows redoing trial phase when trial chapter is missing",
|
|
|
216
240
|
await writeJson(join(rootDir, "staging/quickstart/contracts/hero.json"), { id: "hero", display_name: "阿宁", contracts: [] });
|
|
217
241
|
await writeJson(join(rootDir, "staging/quickstart/style-profile.json"), { source_type: "template" });
|
|
218
242
|
const next = await computeNextStep(rootDir, await readCheckpoint(rootDir));
|
|
219
|
-
assert.equal(next.step, "quickstart:
|
|
220
|
-
assert.
|
|
243
|
+
assert.equal(next.step, "quickstart:f0");
|
|
244
|
+
assert.match(next.reason, /quickstart:recovery_blocked/);
|
|
245
|
+
assert.equal(next.evidence.recovery_blocked.checkpoint_phase, "trial");
|
|
246
|
+
assert.equal(next.evidence.recovery_blocked.expected_path, "volumes/vol-01/outline.md");
|
|
221
247
|
});
|
|
222
|
-
test("computeNextStep continues forward when checkpoint quickstart_phase is consistent with
|
|
248
|
+
test("computeNextStep continues forward into f0 when checkpoint quickstart_phase is consistent with style artifacts", async () => {
|
|
223
249
|
const rootDir = await mkdtemp(join(tmpdir(), "novel-quickstart-recover-happy-"));
|
|
224
250
|
await writeJson(join(rootDir, ".checkpoint.json"), {
|
|
225
251
|
last_completed_chapter: 0,
|
|
@@ -233,6 +259,25 @@ test("computeNextStep continues forward when checkpoint quickstart_phase is cons
|
|
|
233
259
|
await writeJson(join(rootDir, "staging/quickstart/contracts/hero.json"), { id: "hero", display_name: "阿宁", contracts: [] });
|
|
234
260
|
await writeJson(join(rootDir, "staging/quickstart/style-profile.json"), { source_type: "template" });
|
|
235
261
|
const next = await computeNextStep(rootDir, await readCheckpoint(rootDir));
|
|
262
|
+
assert.equal(next.step, "quickstart:f0");
|
|
263
|
+
assert.equal(next.reason, "quickstart:f0");
|
|
264
|
+
assert.equal(next.evidence.recovery_blocked ?? null, null);
|
|
265
|
+
});
|
|
266
|
+
test("computeNextStep resumes from quickstart_phase=f0 into trial when mini-planning is committed", async () => {
|
|
267
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-quickstart-resume-f0-"));
|
|
268
|
+
await writeJson(join(rootDir, ".checkpoint.json"), {
|
|
269
|
+
last_completed_chapter: 0,
|
|
270
|
+
current_volume: 1,
|
|
271
|
+
orchestrator_state: "QUICK_START",
|
|
272
|
+
pipeline_stage: null,
|
|
273
|
+
inflight_chapter: null,
|
|
274
|
+
quickstart_phase: "f0"
|
|
275
|
+
});
|
|
276
|
+
await writeJson(join(rootDir, "staging/quickstart/rules.json"), { rules: [] });
|
|
277
|
+
await writeJson(join(rootDir, "staging/quickstart/contracts/hero.json"), { id: "hero", display_name: "阿宁", contracts: [] });
|
|
278
|
+
await writeJson(join(rootDir, "staging/quickstart/style-profile.json"), { source_type: "template" });
|
|
279
|
+
await writeCommittedMiniPlanning(rootDir);
|
|
280
|
+
const next = await computeNextStep(rootDir, await readCheckpoint(rootDir));
|
|
236
281
|
assert.equal(next.step, "quickstart:trial");
|
|
237
282
|
assert.equal(next.reason, "quickstart:trial");
|
|
238
283
|
assert.equal(next.evidence.recovery_blocked ?? null, null);
|
|
@@ -267,6 +312,26 @@ test("buildInstructionPacket (quickstart) includes NOVEL_ASK gate when provided"
|
|
|
267
312
|
assert.equal(built.packet.novel_ask.topic, questionSpec.topic);
|
|
268
313
|
assert.equal(built.packet.expected_outputs[0].path, answerPath);
|
|
269
314
|
});
|
|
315
|
+
test("advance quickstart:results requires committed mini-planning artifacts", async () => {
|
|
316
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-quickstart-results-requires-f0-"));
|
|
317
|
+
await writeJson(join(rootDir, ".checkpoint.json"), {
|
|
318
|
+
last_completed_chapter: 0,
|
|
319
|
+
current_volume: 1,
|
|
320
|
+
orchestrator_state: "QUICK_START",
|
|
321
|
+
pipeline_stage: null,
|
|
322
|
+
inflight_chapter: null,
|
|
323
|
+
volume_pipeline_stage: null
|
|
324
|
+
});
|
|
325
|
+
await writeJson(join(rootDir, "staging/quickstart/rules.json"), { rules: [] });
|
|
326
|
+
await writeJson(join(rootDir, "staging/quickstart/contracts/hero.json"), { id: "hero", display_name: "阿宁", contracts: [] });
|
|
327
|
+
await writeJson(join(rootDir, "staging/quickstart/style-profile.json"), { source_type: "template" });
|
|
328
|
+
await writeText(join(rootDir, "staging/quickstart/trial-chapter.md"), `# 试写章
|
|
329
|
+
|
|
330
|
+
(测试)
|
|
331
|
+
`);
|
|
332
|
+
await writeJson(join(rootDir, "staging/quickstart/evaluation.json"), { overall: 4.2, recommendation: "pass" });
|
|
333
|
+
await assert.rejects(() => advanceCheckpointForStep({ rootDir, step: { kind: "quickstart", phase: "results" } }), /Missing committed quickstart mini-planning artifacts/);
|
|
334
|
+
});
|
|
270
335
|
test("advance quickstart:results commits artifacts and transitions to VOL_PLANNING", async () => {
|
|
271
336
|
const rootDir = await mkdtemp(join(tmpdir(), "novel-quickstart-commit-"));
|
|
272
337
|
await writeJson(join(rootDir, ".checkpoint.json"), {
|
|
@@ -292,6 +357,7 @@ test("advance quickstart:results commits artifacts and transitions to VOL_PLANNI
|
|
|
292
357
|
});
|
|
293
358
|
await writeJson(join(rootDir, "staging/quickstart/contracts/hero.json"), { id: "hero", display_name: "阿宁", contracts: [] });
|
|
294
359
|
await writeJson(join(rootDir, "staging/quickstart/style-profile.json"), { source_type: "template" });
|
|
360
|
+
await writeCommittedMiniPlanning(rootDir);
|
|
295
361
|
await writeText(join(rootDir, "staging/quickstart/trial-chapter.md"), `# 试写章\n\n(测试)\n`);
|
|
296
362
|
await writeJson(join(rootDir, "staging/quickstart/evaluation.json"), { overall: 4.2, recommendation: "pass" });
|
|
297
363
|
const updated = await advanceCheckpointForStep({ rootDir, step: { kind: "quickstart", phase: "results" } });
|
|
@@ -334,6 +400,7 @@ test("advance quickstart:results validates all contracts (not just a slice)", as
|
|
|
334
400
|
]
|
|
335
401
|
});
|
|
336
402
|
await writeJson(join(rootDir, "staging/quickstart/style-profile.json"), { source_type: "template" });
|
|
403
|
+
await writeCommittedMiniPlanning(rootDir);
|
|
337
404
|
await writeText(join(rootDir, "staging/quickstart/trial-chapter.md"), `# 试写章\n\n(测试)\n`);
|
|
338
405
|
await writeJson(join(rootDir, "staging/quickstart/evaluation.json"), { overall: 4.2, recommendation: "pass" });
|
|
339
406
|
for (let i = 1; i <= 10; i++) {
|