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
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import { attachScoringWeightsToEval, computeEffectiveScoringWeights, parseGenreWeightProfiles } from "../scoring-weights.js";
|
|
7
|
+
const baseConfigRaw = {
|
|
8
|
+
schema_version: 1,
|
|
9
|
+
dimensions: [
|
|
10
|
+
"plot_logic",
|
|
11
|
+
"character",
|
|
12
|
+
"immersion",
|
|
13
|
+
"foreshadowing",
|
|
14
|
+
"pacing",
|
|
15
|
+
"style_naturalness",
|
|
16
|
+
"emotional_impact",
|
|
17
|
+
"storyline_coherence",
|
|
18
|
+
"hook_strength"
|
|
19
|
+
],
|
|
20
|
+
normalization: {
|
|
21
|
+
method: "scale_to_sum",
|
|
22
|
+
sum_to: 1.0,
|
|
23
|
+
tolerance: 0.0001
|
|
24
|
+
},
|
|
25
|
+
default_profile_by_drive_type: {
|
|
26
|
+
plot: "plot:v1",
|
|
27
|
+
character: "character:v1"
|
|
28
|
+
},
|
|
29
|
+
platform_multipliers: {
|
|
30
|
+
fanqie: { hook_strength: 1.5, pacing: 1.3 },
|
|
31
|
+
qidian: { immersion: 1.3 },
|
|
32
|
+
jinjiang: { character: 1.3, style_naturalness: 1.3, emotional_impact: 1.2 }
|
|
33
|
+
},
|
|
34
|
+
profiles: {
|
|
35
|
+
"plot:v1": {
|
|
36
|
+
drive_type: "plot",
|
|
37
|
+
weights: {
|
|
38
|
+
plot_logic: 0.22,
|
|
39
|
+
character: 0.16,
|
|
40
|
+
immersion: 0.13,
|
|
41
|
+
foreshadowing: 0.11,
|
|
42
|
+
pacing: 0.08,
|
|
43
|
+
style_naturalness: 0.11,
|
|
44
|
+
emotional_impact: 0.06,
|
|
45
|
+
storyline_coherence: 0.07,
|
|
46
|
+
hook_strength: 0.06
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"character:v1": {
|
|
50
|
+
drive_type: "character",
|
|
51
|
+
weights: {
|
|
52
|
+
plot_logic: 0.13,
|
|
53
|
+
character: 0.24,
|
|
54
|
+
immersion: 0.16,
|
|
55
|
+
foreshadowing: 0.09,
|
|
56
|
+
pacing: 0.06,
|
|
57
|
+
style_naturalness: 0.12,
|
|
58
|
+
emotional_impact: 0.1,
|
|
59
|
+
storyline_coherence: 0.05,
|
|
60
|
+
hook_strength: 0.05
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
const hookPolicy = { required: true, min_strength: 3, allowed_types: ["question"], fix_strategy: "hook-fix" };
|
|
66
|
+
test("parseGenreWeightProfiles accepts platform_multipliers", () => {
|
|
67
|
+
const config = parseGenreWeightProfiles(baseConfigRaw, "genre-weight-profiles.json");
|
|
68
|
+
assert.equal(config.platform_multipliers?.fanqie?.hook_strength, 1.5);
|
|
69
|
+
assert.equal(config.platform_multipliers?.jinjiang?.emotional_impact, 1.2);
|
|
70
|
+
});
|
|
71
|
+
test("parseGenreWeightProfiles rejects non-canonical platform_multipliers keys", () => {
|
|
72
|
+
assert.throws(() => parseGenreWeightProfiles({
|
|
73
|
+
...baseConfigRaw,
|
|
74
|
+
platform_multipliers: { ...baseConfigRaw.platform_multipliers, tomato: { pacing: 1.2 } }
|
|
75
|
+
}, "genre-weight-profiles.json"), /unknown platform_multipliers key 'tomato'/i);
|
|
76
|
+
});
|
|
77
|
+
test("computeEffectiveScoringWeights applies platform multipliers and renormalizes", () => {
|
|
78
|
+
const config = parseGenreWeightProfiles(baseConfigRaw, "genre-weight-profiles.json");
|
|
79
|
+
const base = computeEffectiveScoringWeights({
|
|
80
|
+
config,
|
|
81
|
+
scoring: { genre_drive_type: "plot", weight_profile_id: "plot:v1" },
|
|
82
|
+
hookPolicy
|
|
83
|
+
});
|
|
84
|
+
const fanqie = computeEffectiveScoringWeights({
|
|
85
|
+
config,
|
|
86
|
+
scoring: { genre_drive_type: "plot", weight_profile_id: "plot:v1" },
|
|
87
|
+
hookPolicy,
|
|
88
|
+
platformId: "fanqie"
|
|
89
|
+
});
|
|
90
|
+
const sum = fanqie.dimensions.reduce((total, dim) => total + (fanqie.weights[dim] ?? 0), 0);
|
|
91
|
+
assert.ok(Math.abs(sum - 1.0) < 0.0001);
|
|
92
|
+
assert.ok((fanqie.weights.hook_strength ?? 0) > (base.weights.hook_strength ?? 0));
|
|
93
|
+
assert.ok((fanqie.weights.pacing ?? 0) > (base.weights.pacing ?? 0));
|
|
94
|
+
});
|
|
95
|
+
test("computeEffectiveScoringWeights canonicalizes tomato to fanqie", () => {
|
|
96
|
+
const config = parseGenreWeightProfiles(baseConfigRaw, "genre-weight-profiles.json");
|
|
97
|
+
const fanqie = computeEffectiveScoringWeights({
|
|
98
|
+
config,
|
|
99
|
+
scoring: { genre_drive_type: "plot", weight_profile_id: "plot:v1" },
|
|
100
|
+
hookPolicy,
|
|
101
|
+
platformId: "fanqie"
|
|
102
|
+
});
|
|
103
|
+
const tomato = computeEffectiveScoringWeights({
|
|
104
|
+
config,
|
|
105
|
+
scoring: { genre_drive_type: "plot", weight_profile_id: "plot:v1" },
|
|
106
|
+
hookPolicy,
|
|
107
|
+
platformId: "tomato"
|
|
108
|
+
});
|
|
109
|
+
assert.deepEqual(tomato.weights, fanqie.weights);
|
|
110
|
+
});
|
|
111
|
+
test("computeEffectiveScoringWeights leaves weights unchanged when platform multipliers are absent", () => {
|
|
112
|
+
const raw = { ...baseConfigRaw };
|
|
113
|
+
delete raw.platform_multipliers;
|
|
114
|
+
const config = parseGenreWeightProfiles(raw, "genre-weight-profiles.json");
|
|
115
|
+
const base = computeEffectiveScoringWeights({
|
|
116
|
+
config,
|
|
117
|
+
scoring: { genre_drive_type: "plot", weight_profile_id: "plot:v1" },
|
|
118
|
+
hookPolicy
|
|
119
|
+
});
|
|
120
|
+
const withPlatform = computeEffectiveScoringWeights({
|
|
121
|
+
config,
|
|
122
|
+
scoring: { genre_drive_type: "plot", weight_profile_id: "plot:v1" },
|
|
123
|
+
hookPolicy,
|
|
124
|
+
platformId: "qidian"
|
|
125
|
+
});
|
|
126
|
+
assert.deepEqual(withPlatform.weights, base.weights);
|
|
127
|
+
});
|
|
128
|
+
test("attachScoringWeightsToEval writes metadata and per-dimension weights", async () => {
|
|
129
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-attach-scoring-weights-"));
|
|
130
|
+
const evalAbsPath = join(rootDir, "chapter-001-eval.json");
|
|
131
|
+
await writeFile(evalAbsPath, `${JSON.stringify({
|
|
132
|
+
chapter: 1,
|
|
133
|
+
scores: {
|
|
134
|
+
plot_logic: { score: 4 },
|
|
135
|
+
character: { score: 4 },
|
|
136
|
+
immersion: { score: 4 },
|
|
137
|
+
foreshadowing: { score: 4 },
|
|
138
|
+
pacing: { score: 4 },
|
|
139
|
+
style_naturalness: { score: 4 },
|
|
140
|
+
emotional_impact: { score: 4 },
|
|
141
|
+
storyline_coherence: { score: 4 },
|
|
142
|
+
hook_strength: { score: 4 }
|
|
143
|
+
}
|
|
144
|
+
}, null, 2)}\n`, "utf8");
|
|
145
|
+
const config = parseGenreWeightProfiles(baseConfigRaw, "genre-weight-profiles.json");
|
|
146
|
+
await attachScoringWeightsToEval({
|
|
147
|
+
evalAbsPath,
|
|
148
|
+
evalRelPath: "staging/evaluations/chapter-001-eval.json",
|
|
149
|
+
platformProfile: {
|
|
150
|
+
schema_version: 1,
|
|
151
|
+
platform: "tomato",
|
|
152
|
+
created_at: "2026-01-01T00:00:00Z",
|
|
153
|
+
word_count: { target_min: 1, target_max: 2, hard_min: 1, hard_max: 2 },
|
|
154
|
+
info_load: { max_new_entities_per_chapter: 1, max_unknown_entities_per_chapter: 1, max_new_terms_per_1k_words: 1 },
|
|
155
|
+
compliance: { banned_words: [], duplicate_name_policy: "warn" },
|
|
156
|
+
hook_policy: hookPolicy,
|
|
157
|
+
scoring: { genre_drive_type: "plot", weight_profile_id: "plot:v1" }
|
|
158
|
+
},
|
|
159
|
+
genreWeightProfiles: { relPath: "genre-weight-profiles.json", config }
|
|
160
|
+
});
|
|
161
|
+
const written = JSON.parse(await readFile(evalAbsPath, "utf8"));
|
|
162
|
+
assert.equal(written.scoring_weights.source.platform_profile, "platform-profile.json");
|
|
163
|
+
assert.equal(written.scoring_weights.source.genre_weight_profiles, "genre-weight-profiles.json");
|
|
164
|
+
assert.equal(written.scoring_weights.weights.hook_strength > 0, true);
|
|
165
|
+
assert.equal(written.scores.plot_logic.weight, written.scoring_weights.weights.plot_logic);
|
|
166
|
+
assert.equal(written.scores.hook_strength.weight, written.scoring_weights.weights.hook_strength);
|
|
167
|
+
});
|
|
168
|
+
test("attachScoringWeightsToEval rejects evals missing required score dimensions", async () => {
|
|
169
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-attach-scoring-weights-missing-dim-"));
|
|
170
|
+
const evalAbsPath = join(rootDir, "chapter-001-eval.json");
|
|
171
|
+
await writeFile(evalAbsPath, `${JSON.stringify({
|
|
172
|
+
chapter: 1,
|
|
173
|
+
scores: {
|
|
174
|
+
plot_logic: { score: 4 }
|
|
175
|
+
}
|
|
176
|
+
}, null, 2)}\n`, "utf8");
|
|
177
|
+
const config = parseGenreWeightProfiles(baseConfigRaw, "genre-weight-profiles.json");
|
|
178
|
+
await assert.rejects(() => attachScoringWeightsToEval({
|
|
179
|
+
evalAbsPath,
|
|
180
|
+
evalRelPath: "staging/evaluations/chapter-001-eval.json",
|
|
181
|
+
platformProfile: {
|
|
182
|
+
schema_version: 1,
|
|
183
|
+
platform: "qidian",
|
|
184
|
+
created_at: "2026-01-01T00:00:00Z",
|
|
185
|
+
word_count: { target_min: 1, target_max: 2, hard_min: 1, hard_max: 2 },
|
|
186
|
+
info_load: { max_new_entities_per_chapter: 1, max_unknown_entities_per_chapter: 1, max_new_terms_per_1k_words: 1 },
|
|
187
|
+
compliance: { banned_words: [], duplicate_name_policy: "warn" },
|
|
188
|
+
hook_policy: hookPolicy,
|
|
189
|
+
scoring: { genre_drive_type: "plot", weight_profile_id: "plot:v1" }
|
|
190
|
+
},
|
|
191
|
+
genreWeightProfiles: { relPath: "genre-weight-profiles.json", config }
|
|
192
|
+
}), /missing score dimensions/i);
|
|
193
|
+
});
|
|
@@ -7,6 +7,7 @@ test("formatStepId formats chapter ids with pad3", () => {
|
|
|
7
7
|
test("formatStepId formats volume/quickstart/review ids", () => {
|
|
8
8
|
assert.equal(formatStepId({ kind: "volume", phase: "outline" }), "volume:outline");
|
|
9
9
|
assert.equal(formatStepId({ kind: "quickstart", phase: "world" }), "quickstart:world");
|
|
10
|
+
assert.equal(formatStepId({ kind: "quickstart", phase: "f0" }), "quickstart:f0");
|
|
10
11
|
assert.equal(formatStepId({ kind: "review", phase: "report" }), "review:report");
|
|
11
12
|
});
|
|
12
13
|
test("parseStepId parses chapter ids and trims whitespace", () => {
|
|
@@ -15,6 +16,7 @@ test("parseStepId parses chapter ids and trims whitespace", () => {
|
|
|
15
16
|
test("parseStepId parses volume/quickstart/review ids", () => {
|
|
16
17
|
assert.deepEqual(parseStepId("volume:validate"), { kind: "volume", phase: "validate" });
|
|
17
18
|
assert.deepEqual(parseStepId("quickstart:trial"), { kind: "quickstart", phase: "trial" });
|
|
19
|
+
assert.deepEqual(parseStepId("quickstart:f0"), { kind: "quickstart", phase: "f0" });
|
|
18
20
|
assert.deepEqual(parseStepId("review:cleanup"), { kind: "review", phase: "cleanup" });
|
|
19
21
|
});
|
|
20
22
|
test("parseStepId rejects unknown kind and invalid phases", () => {
|
|
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
|
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
import test from "node:test";
|
|
6
6
|
import { validateStep } from "../validate.js";
|
|
7
|
+
import { writeCommittedMiniPlanning } from "./helpers/quickstart-mini-planning.js";
|
|
7
8
|
async function writeText(absPath, contents) {
|
|
8
9
|
await mkdir(dirname(absPath), { recursive: true });
|
|
9
10
|
await writeFile(absPath, contents, "utf8");
|
|
@@ -64,6 +65,7 @@ test("validateStep(quickstart:trial) rejects empty trial chapter", async () => {
|
|
|
64
65
|
await writeJson(join(rootDir, "staging/quickstart/rules.json"), { rules: [] });
|
|
65
66
|
await writeJson(join(rootDir, "staging/quickstart/contracts/hero.json"), { id: "hero", display_name: "阿宁", contracts: [] });
|
|
66
67
|
await writeJson(join(rootDir, "staging/quickstart/style-profile.json"), { source_type: "template" });
|
|
68
|
+
await writeCommittedMiniPlanning(rootDir);
|
|
67
69
|
await writeText(join(rootDir, "staging/quickstart/trial-chapter.md"), "");
|
|
68
70
|
await assert.rejects(() => validateStep({
|
|
69
71
|
rootDir,
|
package/dist/advance.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { copyFile, readdir } from "node:fs/promises";
|
|
1
|
+
import { copyFile, readdir, rename } from "node:fs/promises";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { readCheckpoint, writeCheckpoint } from "./checkpoint.js";
|
|
4
4
|
import { NovelCliError } from "./errors.js";
|
|
@@ -7,6 +7,7 @@ import { withWriteLock } from "./lock.js";
|
|
|
7
7
|
import { QUICKSTART_FINAL_RELS, QUICKSTART_STAGING_RELS } from "./quickstart.js";
|
|
8
8
|
import { chapterRelPaths, formatStepId, titleFixSnapshotRel } from "./steps.js";
|
|
9
9
|
import { validateStep } from "./validate.js";
|
|
10
|
+
import { volumeFinalRelPaths, volumeStagingRelPaths } from "./volume-planning.js";
|
|
10
11
|
import { VOL_REVIEW_RELS } from "./volume-review.js";
|
|
11
12
|
function stageForStep(step) {
|
|
12
13
|
switch (step.stage) {
|
|
@@ -151,6 +152,26 @@ export async function advanceCheckpointForStep(args) {
|
|
|
151
152
|
await copyFile(join(contractsAbs, name), join(activeDirAbs, name));
|
|
152
153
|
}
|
|
153
154
|
};
|
|
155
|
+
const commitQuickStartMiniPlanning = async () => {
|
|
156
|
+
const staging = volumeStagingRelPaths(1);
|
|
157
|
+
const final = volumeFinalRelPaths(1);
|
|
158
|
+
const stagingAbs = join(args.rootDir, staging.dir);
|
|
159
|
+
if (!(await pathExists(stagingAbs))) {
|
|
160
|
+
throw new NovelCliError(`Missing staging volume directory: ${staging.dir}`, 2);
|
|
161
|
+
}
|
|
162
|
+
const finalAbs = join(args.rootDir, final.dir);
|
|
163
|
+
if (await pathExists(finalAbs)) {
|
|
164
|
+
throw new NovelCliError(`Refusing to overwrite existing destination: ${final.dir}`, 2);
|
|
165
|
+
}
|
|
166
|
+
await ensureDir(join(args.rootDir, "volumes"));
|
|
167
|
+
try {
|
|
168
|
+
await rename(stagingAbs, finalAbs);
|
|
169
|
+
}
|
|
170
|
+
catch (err) {
|
|
171
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
172
|
+
throw new NovelCliError(`Failed to move '${staging.dir}' to '${final.dir}': ${message}`, 2);
|
|
173
|
+
}
|
|
174
|
+
};
|
|
154
175
|
return await withWriteLock(args.rootDir, {}, async () => {
|
|
155
176
|
const checkpoint = await readCheckpoint(args.rootDir);
|
|
156
177
|
if (checkpoint.orchestrator_state !== "INIT" && checkpoint.orchestrator_state !== "QUICK_START") {
|
|
@@ -170,7 +191,11 @@ export async function advanceCheckpointForStep(args) {
|
|
|
170
191
|
updated.inflight_chapter = null;
|
|
171
192
|
updated.pipeline_stage = null;
|
|
172
193
|
updated.quickstart_phase = qsStep.phase;
|
|
173
|
-
if (qsStep.phase === "
|
|
194
|
+
if (qsStep.phase === "f0") {
|
|
195
|
+
await commitQuickStartMiniPlanning();
|
|
196
|
+
updated.orchestrator_state = "QUICK_START";
|
|
197
|
+
}
|
|
198
|
+
else if (qsStep.phase === "results") {
|
|
174
199
|
await commitQuickStartArtifacts();
|
|
175
200
|
updated.orchestrator_state = "VOL_PLANNING";
|
|
176
201
|
updated.volume_pipeline_stage = null;
|