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,535 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { pathExists, readJsonFile, readTextFile } from "./fs-utils.js";
|
|
8
|
+
import { resolveProjectRelativePath } from "./safe-path.js";
|
|
9
|
+
import { isPlainObject } from "./type-guards.js";
|
|
10
|
+
const execFileAsync = promisify(execFile);
|
|
11
|
+
const cliRootDir = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
12
|
+
const GENRE_ALIASES = {
|
|
13
|
+
xuanhuan: "xuanhuan",
|
|
14
|
+
"玄幻": "xuanhuan",
|
|
15
|
+
dushi: "dushi",
|
|
16
|
+
"都市": "dushi",
|
|
17
|
+
scifi: "scifi",
|
|
18
|
+
sci_fi: "scifi",
|
|
19
|
+
"sci-fi": "scifi",
|
|
20
|
+
"science-fiction": "scifi",
|
|
21
|
+
"科幻": "scifi",
|
|
22
|
+
history: "history",
|
|
23
|
+
"历史": "history",
|
|
24
|
+
suspense: "suspense",
|
|
25
|
+
mystery: "suspense",
|
|
26
|
+
"悬疑": "suspense",
|
|
27
|
+
romance: "romance",
|
|
28
|
+
"言情": "romance",
|
|
29
|
+
horror: "horror",
|
|
30
|
+
"恐怖": "horror"
|
|
31
|
+
};
|
|
32
|
+
const DEFAULT_SINGLE_SENTENCE_RATIO = { min: 0.25, max: 0.45 };
|
|
33
|
+
const DEFAULT_MAX_PARAGRAPH_CHARS = 100;
|
|
34
|
+
const DEFAULT_ELLIPSIS_MAX = 5;
|
|
35
|
+
const DEFAULT_EXCLAMATION_MAX = 8;
|
|
36
|
+
const GENRE_OVERRIDE_PRESETS = {
|
|
37
|
+
xuanhuan: {
|
|
38
|
+
genre: "xuanhuan",
|
|
39
|
+
paragraph_structure: { single_sentence_ratio: { ...DEFAULT_SINGLE_SENTENCE_RATIO }, max_paragraph_chars: DEFAULT_MAX_PARAGRAPH_CHARS },
|
|
40
|
+
punctuation_rhythm: { ellipsis_max_per_chapter: DEFAULT_ELLIPSIS_MAX, exclamation_max_per_chapter: DEFAULT_EXCLAMATION_MAX, em_dash_max_per_chapter: 0 },
|
|
41
|
+
notes: ["未命中特定类型覆写,使用默认人类写作阈值。"]
|
|
42
|
+
},
|
|
43
|
+
dushi: {
|
|
44
|
+
genre: "dushi",
|
|
45
|
+
paragraph_structure: { single_sentence_ratio: { ...DEFAULT_SINGLE_SENTENCE_RATIO }, max_paragraph_chars: DEFAULT_MAX_PARAGRAPH_CHARS },
|
|
46
|
+
punctuation_rhythm: { ellipsis_max_per_chapter: DEFAULT_ELLIPSIS_MAX, exclamation_max_per_chapter: DEFAULT_EXCLAMATION_MAX, em_dash_max_per_chapter: 0 },
|
|
47
|
+
notes: ["未命中特定类型覆写,使用默认人类写作阈值。"]
|
|
48
|
+
},
|
|
49
|
+
history: {
|
|
50
|
+
genre: "history",
|
|
51
|
+
paragraph_structure: { single_sentence_ratio: { ...DEFAULT_SINGLE_SENTENCE_RATIO }, max_paragraph_chars: DEFAULT_MAX_PARAGRAPH_CHARS },
|
|
52
|
+
punctuation_rhythm: { ellipsis_max_per_chapter: DEFAULT_ELLIPSIS_MAX, exclamation_max_per_chapter: DEFAULT_EXCLAMATION_MAX, em_dash_max_per_chapter: 0 },
|
|
53
|
+
notes: ["未命中特定类型覆写,使用默认人类写作阈值。"]
|
|
54
|
+
},
|
|
55
|
+
scifi: {
|
|
56
|
+
genre: "scifi",
|
|
57
|
+
paragraph_structure: { single_sentence_ratio: { min: 0.15, max: 0.3 }, max_paragraph_chars: 120 },
|
|
58
|
+
punctuation_rhythm: { ellipsis_max_per_chapter: DEFAULT_ELLIPSIS_MAX, exclamation_max_per_chapter: 5, em_dash_max_per_chapter: 0 },
|
|
59
|
+
notes: ["科幻允许更长单段,但感叹号收紧到 ≤5/章。", "“难以形容 / 不可名状”仅可灰度出现,建议 ≤2/章且尽量补具体感官。"]
|
|
60
|
+
},
|
|
61
|
+
suspense: {
|
|
62
|
+
genre: "suspense",
|
|
63
|
+
paragraph_structure: { single_sentence_ratio: { min: 0.2, max: 0.35 }, max_paragraph_chars: 100 },
|
|
64
|
+
punctuation_rhythm: { ellipsis_max_per_chapter: 8, exclamation_max_per_chapter: DEFAULT_EXCLAMATION_MAX, em_dash_max_per_chapter: 0 },
|
|
65
|
+
notes: ["悬疑强调断点与停顿,允许省略号 ≤8/章。"]
|
|
66
|
+
},
|
|
67
|
+
horror: {
|
|
68
|
+
genre: "horror",
|
|
69
|
+
paragraph_structure: { single_sentence_ratio: { min: 0.3, max: 0.5 }, max_paragraph_chars: DEFAULT_MAX_PARAGRAPH_CHARS },
|
|
70
|
+
punctuation_rhythm: { ellipsis_max_per_chapter: 8, exclamation_max_per_chapter: DEFAULT_EXCLAMATION_MAX, em_dash_max_per_chapter: 0 },
|
|
71
|
+
notes: ["恐怖允许更碎的呼吸感,但不能靠标点堆恐惧。"]
|
|
72
|
+
},
|
|
73
|
+
romance: {
|
|
74
|
+
genre: "romance",
|
|
75
|
+
paragraph_structure: { single_sentence_ratio: { ...DEFAULT_SINGLE_SENTENCE_RATIO }, max_paragraph_chars: DEFAULT_MAX_PARAGRAPH_CHARS },
|
|
76
|
+
punctuation_rhythm: { ellipsis_max_per_chapter: DEFAULT_ELLIPSIS_MAX, exclamation_max_per_chapter: DEFAULT_EXCLAMATION_MAX, em_dash_max_per_chapter: 0 },
|
|
77
|
+
notes: ["言情沿用默认阈值,重点依赖语气差异和情感回收。"]
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
function normalizeProjectGenre(raw) {
|
|
81
|
+
if (typeof raw !== "string")
|
|
82
|
+
return null;
|
|
83
|
+
const trimmed = raw.trim();
|
|
84
|
+
if (trimmed.length === 0)
|
|
85
|
+
return null;
|
|
86
|
+
const withoutParens = trimmed.replace(/[((].*$/u, "").trim();
|
|
87
|
+
if (withoutParens.length === 0)
|
|
88
|
+
return null;
|
|
89
|
+
const compact = withoutParens.replace(/\s+/gu, "");
|
|
90
|
+
return GENRE_ALIASES[withoutParens] ?? GENRE_ALIASES[compact] ?? GENRE_ALIASES[compact.toLowerCase()] ?? null;
|
|
91
|
+
}
|
|
92
|
+
async function loadBriefMeta(rootDir) {
|
|
93
|
+
const briefRel = "brief.md";
|
|
94
|
+
const briefAbs = join(rootDir, briefRel);
|
|
95
|
+
if (!(await pathExists(briefAbs)))
|
|
96
|
+
return { genre: null, overrideNotes: null };
|
|
97
|
+
try {
|
|
98
|
+
const lines = (await readTextFile(briefAbs)).split(/\r?\n/u);
|
|
99
|
+
let genre = null;
|
|
100
|
+
let overrideNotes = null;
|
|
101
|
+
for (const line of lines) {
|
|
102
|
+
const genreMatch = /^\s*-\s*\*\*(?:题材|Genre)\*\*[::]\s*(.+?)\s*$/u.exec(line);
|
|
103
|
+
if (genreMatch)
|
|
104
|
+
genre = normalizeProjectGenre(genreMatch[1] ?? "");
|
|
105
|
+
const overrideMatch = /^\s*-\s*\*\*覆写说明\*\*[::]\s*(.+?)\s*$/u.exec(line);
|
|
106
|
+
if (overrideMatch) {
|
|
107
|
+
const note = (overrideMatch[1] ?? "").trim();
|
|
108
|
+
if (note.length > 0 && note !== "{genre_override_notes}")
|
|
109
|
+
overrideNotes = note;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return { genre, overrideNotes };
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return { genre: null, overrideNotes: null };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function asStatisticalLevel(value) {
|
|
119
|
+
return value === "high" || value === "medium" || value === "low" ? value : null;
|
|
120
|
+
}
|
|
121
|
+
function parsePercentRange(note, labelPattern) {
|
|
122
|
+
const match = new RegExp(`${labelPattern.source}[^0-9]*(\\d{1,3})\\s*%\\s*(?:-|–|—|~|~|至|到)\\s*(\\d{1,3})\\s*%`, "u").exec(note);
|
|
123
|
+
if (!match)
|
|
124
|
+
return null;
|
|
125
|
+
const min = Number(match[1]) / 100;
|
|
126
|
+
const max = Number(match[2]) / 100;
|
|
127
|
+
if (!Number.isFinite(min) || !Number.isFinite(max) || min > max)
|
|
128
|
+
return null;
|
|
129
|
+
return { min, max };
|
|
130
|
+
}
|
|
131
|
+
function parseBoundedInt(note, labelPattern) {
|
|
132
|
+
const match = new RegExp(`${labelPattern.source}[^0-9]*(?:≤|<=|不超过|最多)?\\s*(\\d{1,3})\\s*(?:/章|/章|字|个|次)?`, "u").exec(note);
|
|
133
|
+
if (!match)
|
|
134
|
+
return null;
|
|
135
|
+
const value = Number(match[1]);
|
|
136
|
+
return Number.isInteger(value) && value >= 0 ? value : null;
|
|
137
|
+
}
|
|
138
|
+
function applyExplicitGenreOverrideNotes(base, overrideNotes) {
|
|
139
|
+
if (!overrideNotes)
|
|
140
|
+
return { overrides: base, applied: false };
|
|
141
|
+
const singleSentenceRatio = parsePercentRange(overrideNotes, /单句段(?:落)?(?:占比)?/u);
|
|
142
|
+
const paragraphCharMax = parseBoundedInt(overrideNotes, /(?:段长(?:上限)?|段落(?:长度|字数)?(?:上限)?|单段(?:可放宽到)?)/u);
|
|
143
|
+
const ellipsisMax = parseBoundedInt(overrideNotes, /省略号(?:上限)?/u);
|
|
144
|
+
const exclamationMax = parseBoundedInt(overrideNotes, /感叹号(?:上限)?/u);
|
|
145
|
+
let applied = false;
|
|
146
|
+
const overrides = {
|
|
147
|
+
...base,
|
|
148
|
+
paragraph_structure: {
|
|
149
|
+
...base.paragraph_structure,
|
|
150
|
+
...(singleSentenceRatio
|
|
151
|
+
? { single_sentence_ratio: singleSentenceRatio }
|
|
152
|
+
: {}),
|
|
153
|
+
...(paragraphCharMax !== null ? { max_paragraph_chars: paragraphCharMax } : {})
|
|
154
|
+
},
|
|
155
|
+
punctuation_rhythm: {
|
|
156
|
+
...base.punctuation_rhythm,
|
|
157
|
+
...(ellipsisMax !== null ? { ellipsis_max_per_chapter: ellipsisMax } : {}),
|
|
158
|
+
...(exclamationMax !== null ? { exclamation_max_per_chapter: exclamationMax } : {})
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
if (singleSentenceRatio || paragraphCharMax !== null || ellipsisMax !== null || exclamationMax !== null) {
|
|
162
|
+
applied = true;
|
|
163
|
+
}
|
|
164
|
+
return { overrides, applied };
|
|
165
|
+
}
|
|
166
|
+
function toStructuralLintConfig(overrides) {
|
|
167
|
+
return {
|
|
168
|
+
thresholds: {
|
|
169
|
+
l5: {
|
|
170
|
+
single_sentence_ratio: [
|
|
171
|
+
overrides.paragraph_structure.single_sentence_ratio.min,
|
|
172
|
+
overrides.paragraph_structure.single_sentence_ratio.max
|
|
173
|
+
],
|
|
174
|
+
paragraph_char_max: overrides.paragraph_structure.max_paragraph_chars
|
|
175
|
+
},
|
|
176
|
+
l6: {
|
|
177
|
+
ellipsis_per_chapter_max: overrides.punctuation_rhythm.ellipsis_max_per_chapter,
|
|
178
|
+
exclamation_per_chapter_max: overrides.punctuation_rhythm.exclamation_max_per_chapter,
|
|
179
|
+
em_dash_per_chapter_max: overrides.punctuation_rhythm.em_dash_max_per_chapter
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
export async function loadAntiAiStatisticalTargets(rootDir) {
|
|
185
|
+
const relPath = "style-profile.json";
|
|
186
|
+
const absPath = join(rootDir, relPath);
|
|
187
|
+
if (!(await pathExists(absPath)))
|
|
188
|
+
return null;
|
|
189
|
+
try {
|
|
190
|
+
const raw = await readJsonFile(absPath);
|
|
191
|
+
if (!isPlainObject(raw))
|
|
192
|
+
return null;
|
|
193
|
+
const obj = raw;
|
|
194
|
+
const sentenceStd = typeof obj.sentence_length_std_dev === "number" && Number.isFinite(obj.sentence_length_std_dev)
|
|
195
|
+
? obj.sentence_length_std_dev
|
|
196
|
+
: null;
|
|
197
|
+
const paragraphCv = typeof obj.paragraph_length_cv === "number" && Number.isFinite(obj.paragraph_length_cv)
|
|
198
|
+
? obj.paragraph_length_cv
|
|
199
|
+
: null;
|
|
200
|
+
const vocabularyRichness = asStatisticalLevel(obj.vocabulary_richness) ?? "medium";
|
|
201
|
+
const registerMixing = asStatisticalLevel(obj.register_mixing) ?? "medium";
|
|
202
|
+
const emotionalVolatility = asStatisticalLevel(obj.emotional_volatility) ?? "medium";
|
|
203
|
+
return {
|
|
204
|
+
source: { style_profile: relPath },
|
|
205
|
+
sentence_length_std_dev: {
|
|
206
|
+
target: sentenceStd,
|
|
207
|
+
fallback_range: [8, 18],
|
|
208
|
+
fallback_applied: sentenceStd === null
|
|
209
|
+
},
|
|
210
|
+
paragraph_length_cv: {
|
|
211
|
+
target: paragraphCv,
|
|
212
|
+
fallback_range: [0.4, 1.2],
|
|
213
|
+
fallback_applied: paragraphCv === null
|
|
214
|
+
},
|
|
215
|
+
vocabulary_diversity: {
|
|
216
|
+
target: vocabularyRichness,
|
|
217
|
+
source_field: "vocabulary_richness",
|
|
218
|
+
fallback_applied: asStatisticalLevel(obj.vocabulary_richness) === null
|
|
219
|
+
},
|
|
220
|
+
narration_connectors: {
|
|
221
|
+
target: 0,
|
|
222
|
+
source_field: "ai-blacklist.category_metadata.narration_connector",
|
|
223
|
+
fallback_applied: false,
|
|
224
|
+
note: "叙述连接词默认按 0 命中控制;style-profile 当前无独立字段,依赖 ai-blacklist + writing_directives。"
|
|
225
|
+
},
|
|
226
|
+
register_mixing: {
|
|
227
|
+
target: registerMixing,
|
|
228
|
+
fallback_applied: asStatisticalLevel(obj.register_mixing) === null
|
|
229
|
+
},
|
|
230
|
+
emotional_arc: {
|
|
231
|
+
target: emotionalVolatility,
|
|
232
|
+
source_field: "emotional_volatility",
|
|
233
|
+
fallback_applied: asStatisticalLevel(obj.emotional_volatility) === null
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
export async function loadAntiAiGenreOverrides(rootDir) {
|
|
242
|
+
const brief = await loadBriefMeta(rootDir);
|
|
243
|
+
if (!brief.genre)
|
|
244
|
+
return null;
|
|
245
|
+
const preset = GENRE_OVERRIDE_PRESETS[brief.genre];
|
|
246
|
+
const { overrides, applied } = applyExplicitGenreOverrideNotes(preset, brief.overrideNotes);
|
|
247
|
+
return {
|
|
248
|
+
...overrides,
|
|
249
|
+
source: {
|
|
250
|
+
brief: "brief.md",
|
|
251
|
+
mode: applied ? "brief_override_notes" : "brief_genre_fallback"
|
|
252
|
+
},
|
|
253
|
+
explicit_notes: brief.overrideNotes
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
function stripMarkdown(text) {
|
|
257
|
+
return text
|
|
258
|
+
.replace(/```[\s\S]*?```/gu, "\n")
|
|
259
|
+
.replace(/^\s*#{1,6}\s+.*$/gmu, "")
|
|
260
|
+
.replace(/`[^`]+`/gu, "")
|
|
261
|
+
.replace(/\r\n?/gu, "\n");
|
|
262
|
+
}
|
|
263
|
+
function countCompactChars(text) {
|
|
264
|
+
return Array.from(text.replace(/\s+/gu, "")).length;
|
|
265
|
+
}
|
|
266
|
+
function extractParagraphs(text) {
|
|
267
|
+
return stripMarkdown(text)
|
|
268
|
+
.split(/\n\s*\n/gu)
|
|
269
|
+
.map((part) => part.trim())
|
|
270
|
+
.filter((part) => part.length > 0);
|
|
271
|
+
}
|
|
272
|
+
function splitSentences(text) {
|
|
273
|
+
const normalized = stripMarkdown(text).replace(/\n+/gu, "\n").trim();
|
|
274
|
+
if (normalized.length === 0)
|
|
275
|
+
return [];
|
|
276
|
+
const matches = normalized.match(/[^。!?!?……]+(?:……|[。!?!?]+)?/gu) ?? [];
|
|
277
|
+
return matches.map((item) => item.trim()).filter((item) => item.length > 0);
|
|
278
|
+
}
|
|
279
|
+
function coreSentenceLength(sentence) {
|
|
280
|
+
const stripped = sentence.replace(/[,。!?!?;:、“”‘’()()\[\]{}《》【】…—\-\s]/gu, "");
|
|
281
|
+
return Array.from(stripped).length;
|
|
282
|
+
}
|
|
283
|
+
function mean(values) {
|
|
284
|
+
if (values.length === 0)
|
|
285
|
+
return 0;
|
|
286
|
+
return values.reduce((sum, value) => sum + value, 0) / values.length;
|
|
287
|
+
}
|
|
288
|
+
function round3(value) {
|
|
289
|
+
return Math.round(value * 1000) / 1000;
|
|
290
|
+
}
|
|
291
|
+
function stddev(values) {
|
|
292
|
+
if (values.length === 0)
|
|
293
|
+
return 0;
|
|
294
|
+
const avg = mean(values);
|
|
295
|
+
const variance = values.reduce((sum, value) => sum + (value - avg) ** 2, 0) / values.length;
|
|
296
|
+
return Math.sqrt(variance);
|
|
297
|
+
}
|
|
298
|
+
function coefficientOfVariation(values) {
|
|
299
|
+
if (values.length === 0)
|
|
300
|
+
return 0;
|
|
301
|
+
const avg = mean(values);
|
|
302
|
+
if (avg === 0)
|
|
303
|
+
return 0;
|
|
304
|
+
return stddev(values) / avg;
|
|
305
|
+
}
|
|
306
|
+
function collectVocabularyTokens(text) {
|
|
307
|
+
const compact = stripMarkdown(text).replace(/\s+/gu, "");
|
|
308
|
+
const segments = compact.match(/[\p{Script=Han}A-Za-z0-9]+/gu) ?? [];
|
|
309
|
+
const tokens = [];
|
|
310
|
+
for (const segment of segments) {
|
|
311
|
+
const chars = Array.from(segment);
|
|
312
|
+
if (chars.length <= 2) {
|
|
313
|
+
tokens.push(segment);
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
for (let index = 0; index < chars.length - 1; index += 1) {
|
|
317
|
+
tokens.push(`${chars[index]}${chars[index + 1]}`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return tokens;
|
|
321
|
+
}
|
|
322
|
+
function estimateVocabularyRichness(score) {
|
|
323
|
+
if (score >= 0.45)
|
|
324
|
+
return "high";
|
|
325
|
+
if (score >= 0.35)
|
|
326
|
+
return "medium";
|
|
327
|
+
return "low";
|
|
328
|
+
}
|
|
329
|
+
function sentencePatternSignature(sentence) {
|
|
330
|
+
const length = coreSentenceLength(sentence);
|
|
331
|
+
const bucket = length < 14 ? "short" : length < 28 ? "mid" : "long";
|
|
332
|
+
const hasDialogue = /[“”]/u.test(sentence) ? "dialogue" : "narration";
|
|
333
|
+
const punctuation = sentence.includes("?") || sentence.includes("?") ? "question" : sentence.includes("!") || sentence.includes("!") ? "exclaim" : "plain";
|
|
334
|
+
const connector = /(然而|但是|不过|于是|因此|随后|接着|同时|与此同时)/u.test(sentence) ? "connector" : "plain";
|
|
335
|
+
return `${bucket}|${hasDialogue}|${punctuation}|${connector}`;
|
|
336
|
+
}
|
|
337
|
+
function computeSentenceRepetitionRate(sentences) {
|
|
338
|
+
if (sentences.length < 2)
|
|
339
|
+
return 0;
|
|
340
|
+
let maxRepeated = 0;
|
|
341
|
+
for (let start = 0; start < sentences.length; start += 1) {
|
|
342
|
+
const window = sentences.slice(start, start + 5);
|
|
343
|
+
if (window.length < 2)
|
|
344
|
+
continue;
|
|
345
|
+
const counts = new Map();
|
|
346
|
+
for (const sentence of window) {
|
|
347
|
+
const signature = sentencePatternSignature(sentence);
|
|
348
|
+
counts.set(signature, (counts.get(signature) ?? 0) + 1);
|
|
349
|
+
}
|
|
350
|
+
let repeated = 0;
|
|
351
|
+
for (const count of counts.values()) {
|
|
352
|
+
if (count > 1)
|
|
353
|
+
repeated += count - 1;
|
|
354
|
+
}
|
|
355
|
+
if (repeated > maxRepeated)
|
|
356
|
+
maxRepeated = repeated;
|
|
357
|
+
}
|
|
358
|
+
return maxRepeated;
|
|
359
|
+
}
|
|
360
|
+
const HUMANIZE_TECHNIQUES = {
|
|
361
|
+
thought_interrupt: /……/u,
|
|
362
|
+
self_correction: /(不,|不是,|不对,|准确地说|更准确地说|或者说)/u,
|
|
363
|
+
sensory_trigger: /(闻到|嗅到|听见|听到|看见|摸到|触到|尝到|鼻尖|耳边|指尖|喉咙|后颈)/u,
|
|
364
|
+
mundane_detail: /(锅里|汤|鞋底|袖口|杯沿|桌角|门把手|衣角|碗沿|台阶|水汽|灰尘|汗渍)/u,
|
|
365
|
+
register_shift: /(妈的|见鬼|操|得嘞|行吧|您|阁下|在下|贫道|本座)/u
|
|
366
|
+
};
|
|
367
|
+
function computeHumanizeTechniqueVariety(text) {
|
|
368
|
+
let count = 0;
|
|
369
|
+
for (const matcher of Object.values(HUMANIZE_TECHNIQUES)) {
|
|
370
|
+
if (matcher.test(text))
|
|
371
|
+
count += 1;
|
|
372
|
+
}
|
|
373
|
+
return count;
|
|
374
|
+
}
|
|
375
|
+
async function findAvailableScriptPath(rootDir, scriptRel) {
|
|
376
|
+
try {
|
|
377
|
+
const candidate = resolveProjectRelativePath(rootDir, scriptRel, scriptRel);
|
|
378
|
+
if (await pathExists(candidate))
|
|
379
|
+
return candidate;
|
|
380
|
+
}
|
|
381
|
+
catch {
|
|
382
|
+
// Fall back to the packaged script path below.
|
|
383
|
+
}
|
|
384
|
+
const packaged = join(cliRootDir, scriptRel);
|
|
385
|
+
return (await pathExists(packaged)) ? packaged : null;
|
|
386
|
+
}
|
|
387
|
+
async function runJsonScript(rootDir, scriptRel, args) {
|
|
388
|
+
const scriptAbs = await findAvailableScriptPath(rootDir, scriptRel);
|
|
389
|
+
if (!scriptAbs)
|
|
390
|
+
return null;
|
|
391
|
+
try {
|
|
392
|
+
const { stdout } = await execFileAsync("bash", [scriptAbs, ...args], { cwd: rootDir, maxBuffer: 1024 * 1024 * 8, timeout: 30_000 });
|
|
393
|
+
const raw = JSON.parse(stdout);
|
|
394
|
+
return isPlainObject(raw) ? raw : null;
|
|
395
|
+
}
|
|
396
|
+
catch {
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
async function resolveProjectFileIfSafe(rootDir, relPath) {
|
|
401
|
+
try {
|
|
402
|
+
return resolveProjectRelativePath(rootDir, relPath, relPath);
|
|
403
|
+
}
|
|
404
|
+
catch {
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
async function markJudgeContextDegraded(rootDir, degraded) {
|
|
409
|
+
if (await pathExists(join(rootDir, "ai-blacklist.json")))
|
|
410
|
+
degraded.blacklist_lint = true;
|
|
411
|
+
if (await findAvailableScriptPath(rootDir, "scripts/lint-structural.sh"))
|
|
412
|
+
degraded.structural_rule_violations = true;
|
|
413
|
+
}
|
|
414
|
+
async function runBlacklistLint(rootDir, chapterRel) {
|
|
415
|
+
const chapterAbs = await resolveProjectFileIfSafe(rootDir, chapterRel);
|
|
416
|
+
const blacklistRel = "ai-blacklist.json";
|
|
417
|
+
const blacklistAbs = join(rootDir, blacklistRel);
|
|
418
|
+
if (!chapterAbs || !(await pathExists(chapterAbs)) || !(await pathExists(blacklistAbs)))
|
|
419
|
+
return null;
|
|
420
|
+
const raw = await runJsonScript(rootDir, "scripts/lint-blacklist.sh", [chapterRel, blacklistRel]);
|
|
421
|
+
if (!raw)
|
|
422
|
+
return null;
|
|
423
|
+
const hits_per_kchars = typeof raw.hits_per_kchars === "number" ? raw.hits_per_kchars : 0;
|
|
424
|
+
const hits = Array.isArray(raw.hits)
|
|
425
|
+
? raw.hits.filter(isPlainObject).map((item) => ({
|
|
426
|
+
word: typeof item.word === "string" ? item.word : "",
|
|
427
|
+
count: typeof item.count === "number" ? item.count : 0,
|
|
428
|
+
category: typeof item.category === "string" ? item.category : null
|
|
429
|
+
})).filter((item) => item.word.length > 0)
|
|
430
|
+
: [];
|
|
431
|
+
const statistical_profile = isPlainObject(raw.statistical_profile)
|
|
432
|
+
? {
|
|
433
|
+
blacklist_hit_rate: typeof raw.statistical_profile.blacklist_hit_rate === "number" ? raw.statistical_profile.blacklist_hit_rate : undefined,
|
|
434
|
+
narration_connector_count: typeof raw.statistical_profile.narration_connector_count === "number" ? raw.statistical_profile.narration_connector_count : undefined
|
|
435
|
+
}
|
|
436
|
+
: undefined;
|
|
437
|
+
return { hits_per_kchars, hits, statistical_profile };
|
|
438
|
+
}
|
|
439
|
+
function toStructuralLintGenre(genre) {
|
|
440
|
+
return genre;
|
|
441
|
+
}
|
|
442
|
+
async function runStructuralLint(rootDir, chapterRel, overrides) {
|
|
443
|
+
const chapterAbs = await resolveProjectFileIfSafe(rootDir, chapterRel);
|
|
444
|
+
if (!chapterAbs || !(await pathExists(chapterAbs)))
|
|
445
|
+
return null;
|
|
446
|
+
const args = [chapterRel];
|
|
447
|
+
const structuralGenre = toStructuralLintGenre(overrides?.genre ?? null);
|
|
448
|
+
if (structuralGenre)
|
|
449
|
+
args.push("--genre", structuralGenre);
|
|
450
|
+
let tempDir = null;
|
|
451
|
+
try {
|
|
452
|
+
if (overrides) {
|
|
453
|
+
tempDir = await mkdtemp(join(tmpdir(), "novel-anti-ai-struct-"));
|
|
454
|
+
const configPath = join(tempDir, "lint-structural-overrides.json");
|
|
455
|
+
await writeFile(configPath, `${JSON.stringify(toStructuralLintConfig(overrides), null, 2)}\n`, "utf8");
|
|
456
|
+
args.push("--config", configPath);
|
|
457
|
+
}
|
|
458
|
+
const raw = await runJsonScript(rootDir, "scripts/lint-structural.sh", args);
|
|
459
|
+
if (!raw)
|
|
460
|
+
return null;
|
|
461
|
+
return {
|
|
462
|
+
violations: Array.isArray(raw.violations)
|
|
463
|
+
? raw.violations.filter(isPlainObject).map((item) => ({
|
|
464
|
+
rule_id: typeof item.rule_id === "string" ? item.rule_id : "unknown",
|
|
465
|
+
severity: item.severity === "error" ? "error" : "warning",
|
|
466
|
+
location: isPlainObject(item.location)
|
|
467
|
+
? {
|
|
468
|
+
...(typeof item.location.line === "number" ? { line: item.location.line } : {}),
|
|
469
|
+
...(typeof item.location.char_start === "number" ? { char_start: item.location.char_start } : {}),
|
|
470
|
+
...(typeof item.location.char_end === "number" ? { char_end: item.location.char_end } : {}),
|
|
471
|
+
...(typeof item.location.paragraph_index === "number" ? { paragraph_index: item.location.paragraph_index } : {})
|
|
472
|
+
}
|
|
473
|
+
: undefined,
|
|
474
|
+
description: typeof item.description === "string" ? item.description : "",
|
|
475
|
+
suggestion: typeof item.suggestion === "string" ? item.suggestion : undefined
|
|
476
|
+
}))
|
|
477
|
+
: []
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
finally {
|
|
481
|
+
if (tempDir) {
|
|
482
|
+
await rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
export async function loadAntiAiJudgeContext(args) {
|
|
487
|
+
const degraded = {};
|
|
488
|
+
const chapterAbs = await resolveProjectFileIfSafe(args.rootDir, args.chapterRel);
|
|
489
|
+
if (!chapterAbs) {
|
|
490
|
+
await markJudgeContextDegraded(args.rootDir, degraded);
|
|
491
|
+
return { blacklistLint: null, structuralRuleViolations: null, statisticalProfile: null, degraded };
|
|
492
|
+
}
|
|
493
|
+
if (!(await pathExists(chapterAbs))) {
|
|
494
|
+
return { blacklistLint: null, structuralRuleViolations: null, statisticalProfile: null, degraded };
|
|
495
|
+
}
|
|
496
|
+
let chapterText;
|
|
497
|
+
try {
|
|
498
|
+
chapterText = await readTextFile(chapterAbs);
|
|
499
|
+
}
|
|
500
|
+
catch {
|
|
501
|
+
await markJudgeContextDegraded(args.rootDir, degraded);
|
|
502
|
+
return { blacklistLint: null, structuralRuleViolations: null, statisticalProfile: null, degraded };
|
|
503
|
+
}
|
|
504
|
+
const genreOverrides = await loadAntiAiGenreOverrides(args.rootDir);
|
|
505
|
+
const blacklistLint = await runBlacklistLint(args.rootDir, args.chapterRel);
|
|
506
|
+
if (!blacklistLint && (await pathExists(join(args.rootDir, "ai-blacklist.json"))))
|
|
507
|
+
degraded.blacklist_lint = true;
|
|
508
|
+
const structuralLint = await runStructuralLint(args.rootDir, args.chapterRel, genreOverrides);
|
|
509
|
+
if (!structuralLint && (await findAvailableScriptPath(args.rootDir, "scripts/lint-structural.sh")))
|
|
510
|
+
degraded.structural_rule_violations = true;
|
|
511
|
+
const sentences = splitSentences(chapterText);
|
|
512
|
+
const sentenceLengths = sentences.map(coreSentenceLength).filter((value) => value > 0);
|
|
513
|
+
const paragraphs = extractParagraphs(chapterText);
|
|
514
|
+
const paragraphLengths = paragraphs.map(countCompactChars).filter((value) => value > 0);
|
|
515
|
+
const vocabularyTokens = collectVocabularyTokens(chapterText);
|
|
516
|
+
const vocabularyScore = vocabularyTokens.length === 0 ? 0 : new Set(vocabularyTokens).size / vocabularyTokens.length;
|
|
517
|
+
const statisticalProfile = {
|
|
518
|
+
source: "deterministic_lint+heuristic",
|
|
519
|
+
chapter_path: args.chapterRel,
|
|
520
|
+
blacklist_hit_rate: blacklistLint?.statistical_profile?.blacklist_hit_rate ?? blacklistLint?.hits_per_kchars ?? null,
|
|
521
|
+
sentence_repetition_rate: computeSentenceRepetitionRate(sentences),
|
|
522
|
+
sentence_length_std_dev: round3(stddev(sentenceLengths)),
|
|
523
|
+
paragraph_length_cv: round3(coefficientOfVariation(paragraphLengths)),
|
|
524
|
+
vocabulary_diversity_score: round3(vocabularyScore),
|
|
525
|
+
vocabulary_richness_estimate: estimateVocabularyRichness(vocabularyScore),
|
|
526
|
+
narration_connector_count: blacklistLint?.statistical_profile?.narration_connector_count ?? null,
|
|
527
|
+
humanize_technique_variety: computeHumanizeTechniqueVariety(chapterText)
|
|
528
|
+
};
|
|
529
|
+
return {
|
|
530
|
+
blacklistLint,
|
|
531
|
+
structuralRuleViolations: structuralLint?.violations ?? null,
|
|
532
|
+
statisticalProfile,
|
|
533
|
+
degraded
|
|
534
|
+
};
|
|
535
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -79,7 +79,7 @@ function buildProgram(argv) {
|
|
|
79
79
|
.description("Initialize a new novel project directory (.checkpoint.json + staging/** + optional templates).")
|
|
80
80
|
.option("--force", "Overwrite existing files when present.")
|
|
81
81
|
.option("--minimal", "Only create .checkpoint.json + staging/** (skip templates).")
|
|
82
|
-
.option("--platform <id>", "Also write platform-profile.json (+ genre-weight-profiles.json). Supported: qidian|tomato.")
|
|
82
|
+
.option("--platform <id>", "Also write platform-profile.json (+ genre-weight-profiles.json). Supported: qidian|tomato|fanqie|jinjiang.")
|
|
83
83
|
.action(async (localOpts) => {
|
|
84
84
|
const opts = program.opts();
|
|
85
85
|
const json = Boolean(opts.json);
|
|
@@ -104,6 +104,8 @@ function buildProgram(argv) {
|
|
|
104
104
|
process.stdout.write(`OVERWRITE ${p}\n`);
|
|
105
105
|
for (const p of result.skipped)
|
|
106
106
|
process.stdout.write(`SKIP ${p}\n`);
|
|
107
|
+
for (const warning of result.warnings)
|
|
108
|
+
process.stdout.write(`WARN ${warning}\n`);
|
|
107
109
|
process.stdout.write(`Next: novel next\n`);
|
|
108
110
|
});
|
|
109
111
|
program
|
package/dist/commit.js
CHANGED
|
@@ -6,6 +6,7 @@ import { attachClicheLintToEval, computeClicheLintReport, loadWebNovelClicheLint
|
|
|
6
6
|
import { NovelCliError } from "./errors.js";
|
|
7
7
|
import { fingerprintsMatch, hashText } from "./fingerprint.js";
|
|
8
8
|
import { ensureDir, pathExists, readJsonFile, readTextFile, removePath, writeJsonFile } from "./fs-utils.js";
|
|
9
|
+
import { evaluateGateDecisionFromEval, normalizeGateMaxRevisions, normalizeGateRevisionCount } from "./gate-decision.js";
|
|
9
10
|
import { appendEngagementMetricRecord, computeEngagementMetricRecord, computeEngagementReport, loadEngagementMetricsStream, writeEngagementLogs } from "./engagement.js";
|
|
10
11
|
import { computeForeshadowVisibilityReport, loadForeshadowGlobalItems, writeForeshadowVisibilityLogs } from "./foreshadow-visibility.js";
|
|
11
12
|
import { attachHookLedgerToEval, computeHookLedgerUpdate, loadHookLedger, writeHookLedgerFile, writeRetentionLogs } from "./hook-ledger.js";
|
|
@@ -22,6 +23,9 @@ import { rejectPathTraversalInput } from "./safe-path.js";
|
|
|
22
23
|
import { chapterRelPaths, pad2, pad3 } from "./steps.js";
|
|
23
24
|
import { computeTitlePolicyReport, writeTitlePolicyLogs } from "./title-policy.js";
|
|
24
25
|
import { isPlainObject } from "./type-guards.js";
|
|
26
|
+
function isCommitAllowedGateDecision(decision) {
|
|
27
|
+
return decision === "pass" || decision === "force_passed";
|
|
28
|
+
}
|
|
25
29
|
function requireInt(field, value, file) {
|
|
26
30
|
if (typeof value !== "number" || !Number.isInteger(value))
|
|
27
31
|
throw new NovelCliError(`Invalid ${file}: '${field}' must be an int.`, 2);
|
|
@@ -445,6 +449,7 @@ export async function commitChapter(args) {
|
|
|
445
449
|
const volume = checkpoint.current_volume;
|
|
446
450
|
const warnings = [];
|
|
447
451
|
const plan = [];
|
|
452
|
+
const revisionCount = normalizeGateRevisionCount(checkpoint.revision_count);
|
|
448
453
|
// Best-effort volume range resolution (for plan + optional volume-end continuity audits).
|
|
449
454
|
// Never block commit on missing outline/contracts.
|
|
450
455
|
let volumeRange = null;
|
|
@@ -480,6 +485,23 @@ export async function commitChapter(args) {
|
|
|
480
485
|
await ensureFilePresent(args.rootDir, rel.staging.deltaJson);
|
|
481
486
|
await ensureFilePresent(args.rootDir, rel.staging.crossrefJson);
|
|
482
487
|
await ensureFilePresent(args.rootDir, rel.staging.evalJson);
|
|
488
|
+
const evalStagingAbs = join(args.rootDir, rel.staging.evalJson);
|
|
489
|
+
const gateMaxRevisions = normalizeGateMaxRevisions(loadedProfile?.profile.scoring?.max_revisions);
|
|
490
|
+
const commitEvalRaw = await readJsonFile(evalStagingAbs);
|
|
491
|
+
const commitGate = evaluateGateDecisionFromEval({
|
|
492
|
+
evalRaw: commitEvalRaw,
|
|
493
|
+
revision_count: revisionCount,
|
|
494
|
+
...(gateMaxRevisions === null ? {} : { max_revisions: gateMaxRevisions })
|
|
495
|
+
});
|
|
496
|
+
if (!commitGate.ok) {
|
|
497
|
+
if (commitGate.reason === "eval_invalid") {
|
|
498
|
+
throw new NovelCliError(`Cannot commit chapter ${args.chapter}: ${rel.staging.evalJson} must be a JSON object.`, 2);
|
|
499
|
+
}
|
|
500
|
+
throw new NovelCliError(`Cannot commit chapter ${args.chapter}: ${rel.staging.evalJson} is missing a finite overall/overall_final score.`, 2);
|
|
501
|
+
}
|
|
502
|
+
if (!isCommitAllowedGateDecision(commitGate.gate.decision)) {
|
|
503
|
+
throw new NovelCliError(`Cannot commit chapter ${args.chapter}: gate decision is '${commitGate.gate.decision}'. Run 'novel next' and follow the suggested revise/review step first.`, 2);
|
|
504
|
+
}
|
|
483
505
|
// Parse delta early to resolve storyline memory paths and state merge.
|
|
484
506
|
const deltaRaw = await readJsonFile(join(args.rootDir, rel.staging.deltaJson));
|
|
485
507
|
if (!isPlainObject(deltaRaw))
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export const EXCITEMENT_TYPES = ["reversal", "face_slap", "power_up", "reveal", "cliffhanger", "setup"];
|
|
2
|
+
const EXCITEMENT_TYPE_SET = new Set(EXCITEMENT_TYPES);
|
|
3
|
+
export function normalizeExcitementType(raw) {
|
|
4
|
+
if (raw === null || raw === undefined)
|
|
5
|
+
return null;
|
|
6
|
+
if (typeof raw !== "string")
|
|
7
|
+
return undefined;
|
|
8
|
+
const normalized = raw.trim();
|
|
9
|
+
if (normalized.length === 0 || normalized === "null")
|
|
10
|
+
return null;
|
|
11
|
+
return EXCITEMENT_TYPE_SET.has(normalized) ? normalized : undefined;
|
|
12
|
+
}
|