novel-writer-cli 0.0.1
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/LICENSE +21 -0
- package/README.md +103 -0
- package/agents/chapter-writer.md +142 -0
- package/agents/character-weaver.md +117 -0
- package/agents/consistency-auditor.md +85 -0
- package/agents/plot-architect.md +128 -0
- package/agents/quality-judge.md +232 -0
- package/agents/style-analyzer.md +109 -0
- package/agents/style-refiner.md +97 -0
- package/agents/summarizer.md +128 -0
- package/agents/world-builder.md +161 -0
- package/dist/__tests__/character-voice.test.js +445 -0
- package/dist/__tests__/commit-prototype-pollution.test.js +45 -0
- package/dist/__tests__/engagement.test.js +382 -0
- package/dist/__tests__/foreshadow-visibility.test.js +131 -0
- package/dist/__tests__/hook-ledger.test.js +1028 -0
- package/dist/__tests__/naming-lint.test.js +132 -0
- package/dist/__tests__/narrative-health-injection.test.js +359 -0
- package/dist/__tests__/next-step-prejudge-guardrails.test.js +325 -0
- package/dist/__tests__/next-step-title-fix.test.js +153 -0
- package/dist/__tests__/platform-profile.test.js +274 -0
- package/dist/__tests__/promise-ledger.test.js +189 -0
- package/dist/__tests__/readability-lint.test.js +209 -0
- package/dist/__tests__/text-utils.test.js +39 -0
- package/dist/__tests__/title-policy.test.js +147 -0
- package/dist/advance.js +75 -0
- package/dist/character-voice.js +805 -0
- package/dist/checkpoint.js +126 -0
- package/dist/cli.js +563 -0
- package/dist/cliche-lint.js +515 -0
- package/dist/commit.js +1460 -0
- package/dist/consistency-auditor.js +684 -0
- package/dist/engagement.js +687 -0
- package/dist/errors.js +7 -0
- package/dist/fingerprint.js +16 -0
- package/dist/foreshadow-visibility.js +214 -0
- package/dist/fs-utils.js +68 -0
- package/dist/hook-ledger.js +721 -0
- package/dist/hook-policy.js +107 -0
- package/dist/instruction-gates.js +51 -0
- package/dist/instructions.js +406 -0
- package/dist/latest-summary-loader.js +29 -0
- package/dist/lock.js +121 -0
- package/dist/naming-lint.js +531 -0
- package/dist/ner.js +73 -0
- package/dist/next-step.js +408 -0
- package/dist/novel-ask.js +270 -0
- package/dist/output.js +9 -0
- package/dist/platform-constraints.js +518 -0
- package/dist/platform-profile.js +325 -0
- package/dist/prejudge-guardrails.js +370 -0
- package/dist/project.js +40 -0
- package/dist/promise-ledger.js +723 -0
- package/dist/readability-lint.js +555 -0
- package/dist/safe-parse.js +36 -0
- package/dist/safe-path.js +29 -0
- package/dist/scoring-weights.js +290 -0
- package/dist/steps.js +60 -0
- package/dist/text-utils.js +18 -0
- package/dist/title-policy.js +251 -0
- package/dist/type-guards.js +6 -0
- package/dist/validate.js +131 -0
- package/docs/user/README.md +17 -0
- package/docs/user/guardrails.md +179 -0
- package/docs/user/interactive-gates.md +124 -0
- package/docs/user/novel-cli.md +289 -0
- package/docs/user/ops.md +123 -0
- package/docs/user/quick-start.md +97 -0
- package/docs/user/spec-system.md +166 -0
- package/docs/user/storylines.md +144 -0
- package/package.json +48 -0
- package/schemas/README.md +18 -0
- package/schemas/character-voice-drift.schema.json +135 -0
- package/schemas/character-voice-profiles.schema.json +141 -0
- package/schemas/engagement-metrics.schema.json +38 -0
- package/schemas/hook-ledger.schema.json +108 -0
- package/schemas/platform-profile.schema.json +235 -0
- package/schemas/promise-ledger.schema.json +97 -0
- package/scripts/calibrate-quality-judge.sh +91 -0
- package/scripts/compare-regression-runs.sh +86 -0
- package/scripts/lib/_common.py +131 -0
- package/scripts/lib/calibrate_quality_judge.py +312 -0
- package/scripts/lib/compare_regression_runs.py +142 -0
- package/scripts/lib/run_regression.py +621 -0
- package/scripts/lint-blacklist.sh +201 -0
- package/scripts/lint-cliche.sh +370 -0
- package/scripts/lint-readability.sh +404 -0
- package/scripts/query-foreshadow.sh +252 -0
- package/scripts/run-ner.sh +669 -0
- package/scripts/run-regression.sh +122 -0
- package/skills/cli-step/SKILL.md +158 -0
- package/skills/continue/SKILL.md +348 -0
- package/skills/continue/references/context-contracts.md +169 -0
- package/skills/continue/references/continuity-checks.md +187 -0
- package/skills/continue/references/file-protocols.md +64 -0
- package/skills/continue/references/foreshadowing.md +130 -0
- package/skills/continue/references/gate-decision.md +53 -0
- package/skills/continue/references/periodic-maintenance.md +46 -0
- package/skills/novel-writing/SKILL.md +77 -0
- package/skills/novel-writing/references/quality-rubric.md +140 -0
- package/skills/novel-writing/references/style-guide.md +145 -0
- package/skills/start/SKILL.md +458 -0
- package/skills/start/references/quality-review.md +86 -0
- package/skills/start/references/setting-update.md +44 -0
- package/skills/start/references/vol-planning.md +61 -0
- package/skills/start/references/vol-review.md +58 -0
- package/skills/status/SKILL.md +116 -0
- package/skills/status/references/sample-output.md +60 -0
- package/templates/ai-blacklist.json +79 -0
- package/templates/brief-template.md +46 -0
- package/templates/genre-weight-profiles.json +90 -0
- package/templates/novel-ask/example.answer.json +12 -0
- package/templates/novel-ask/example.question.json +51 -0
- package/templates/platform-profile.json +148 -0
- package/templates/style-profile-template.json +58 -0
- package/templates/web-novel-cliche-lint.json +41 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFile, stat } from "node:fs/promises";
|
|
3
|
+
export function hashText(text) {
|
|
4
|
+
return createHash("sha256").update(text, "utf8").digest("hex");
|
|
5
|
+
}
|
|
6
|
+
export async function fingerprintTextFile(absPath) {
|
|
7
|
+
const [s, text] = await Promise.all([stat(absPath), readFile(absPath, "utf8")]);
|
|
8
|
+
return { fingerprint: { size: s.size, mtime_ms: s.mtimeMs, content_hash: hashText(text) }, text };
|
|
9
|
+
}
|
|
10
|
+
export async function fingerprintFile(absPath) {
|
|
11
|
+
const { fingerprint } = await fingerprintTextFile(absPath);
|
|
12
|
+
return fingerprint;
|
|
13
|
+
}
|
|
14
|
+
export function fingerprintsMatch(a, b) {
|
|
15
|
+
return a.size === b.size && a.mtime_ms === b.mtime_ms && a.content_hash === b.content_hash;
|
|
16
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { rename, rm } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { ensureDir, pathExists, readJsonFile, writeJsonFile } from "./fs-utils.js";
|
|
4
|
+
import { pad2, pad3 } from "./steps.js";
|
|
5
|
+
import { isPlainObject } from "./type-guards.js";
|
|
6
|
+
function safeInt(v) {
|
|
7
|
+
return typeof v === "number" && Number.isInteger(v) && v >= 0 ? v : null;
|
|
8
|
+
}
|
|
9
|
+
function safeString(v) {
|
|
10
|
+
return typeof v === "string" && v.trim().length > 0 ? v.trim() : null;
|
|
11
|
+
}
|
|
12
|
+
function normalizeScope(raw) {
|
|
13
|
+
if (raw === "short" || raw === "medium" || raw === "long")
|
|
14
|
+
return raw;
|
|
15
|
+
return "medium";
|
|
16
|
+
}
|
|
17
|
+
function normalizeStatus(raw) {
|
|
18
|
+
if (raw === "planted" || raw === "advanced" || raw === "resolved")
|
|
19
|
+
return raw;
|
|
20
|
+
return "planted";
|
|
21
|
+
}
|
|
22
|
+
export function deriveForeshadowDormancyThresholds(genreDriveType) {
|
|
23
|
+
// Base thresholds in chapters; adjusted by genre_drive_type.
|
|
24
|
+
// Intent: keep long-scope items from going silent too long, without forcing spoilers.
|
|
25
|
+
const base = { short: 6, medium: 12, long: 24 };
|
|
26
|
+
const dt = genreDriveType ?? "";
|
|
27
|
+
const adjust = dt === "suspense" ? -3 : dt === "plot" ? -2 : dt === "character" ? -1 : dt === "slice_of_life" ? 2 : 0;
|
|
28
|
+
const clamp = (n) => Math.max(1, n);
|
|
29
|
+
return {
|
|
30
|
+
short: clamp(base.short + adjust),
|
|
31
|
+
medium: clamp(base.medium + adjust),
|
|
32
|
+
long: clamp(base.long + adjust)
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function normalizeForeshadowList(raw) {
|
|
36
|
+
let list = [];
|
|
37
|
+
if (Array.isArray(raw)) {
|
|
38
|
+
list = raw;
|
|
39
|
+
}
|
|
40
|
+
else if (isPlainObject(raw)) {
|
|
41
|
+
const obj = raw;
|
|
42
|
+
if (Array.isArray(obj.foreshadowing))
|
|
43
|
+
list = obj.foreshadowing;
|
|
44
|
+
}
|
|
45
|
+
const out = [];
|
|
46
|
+
for (const it of list) {
|
|
47
|
+
if (!isPlainObject(it))
|
|
48
|
+
continue;
|
|
49
|
+
const id = safeString(it.id);
|
|
50
|
+
if (!id)
|
|
51
|
+
continue;
|
|
52
|
+
out.push({ ...it, id });
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
function compareStrings(a, b) {
|
|
57
|
+
if (a < b)
|
|
58
|
+
return -1;
|
|
59
|
+
if (a > b)
|
|
60
|
+
return 1;
|
|
61
|
+
return 0;
|
|
62
|
+
}
|
|
63
|
+
function makeLightTouchTasks(args) {
|
|
64
|
+
const scopeHint = args.scope === "short" ? "短期" : args.scope === "medium" ? "中期" : "长期";
|
|
65
|
+
const statusHint = args.status === "resolved" ? "已回收" : args.status === "advanced" ? "已推进" : "已埋设";
|
|
66
|
+
const common = `伏笔「${args.label}」已沉默 ${args.chaptersSince} 章(${scopeHint}/${statusHint})`;
|
|
67
|
+
return {
|
|
68
|
+
planning_task: `${common}。建议在接下来几章安排一次“轻触”回响(象征/道具/一句话提及),保持读者记忆,不要提前兑现。`,
|
|
69
|
+
writing_task: `${common}。本章请用 1 句/1 个意象/1 个小动作做“轻触”回响(不解释、不揭底、不兑现)。`
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
export function computeForeshadowVisibilityReport(args) {
|
|
73
|
+
if (!Number.isInteger(args.asOfChapter) || args.asOfChapter < 0)
|
|
74
|
+
throw new Error(`Invalid asOfChapter: ${String(args.asOfChapter)}`);
|
|
75
|
+
if (!Number.isInteger(args.volume) || args.volume < 0)
|
|
76
|
+
throw new Error(`Invalid volume: ${String(args.volume)}`);
|
|
77
|
+
const thresholds = deriveForeshadowDormancyThresholds(args.genreDriveType);
|
|
78
|
+
const dormant = [];
|
|
79
|
+
for (const it of args.items) {
|
|
80
|
+
const status = normalizeStatus(it.status);
|
|
81
|
+
if (status === "resolved")
|
|
82
|
+
continue;
|
|
83
|
+
const scope = normalizeScope(it.scope);
|
|
84
|
+
const lastUpdated = safeInt(it.last_updated_chapter) ?? safeInt(it.planted_chapter) ?? 0;
|
|
85
|
+
const chaptersSince = Math.max(0, args.asOfChapter - lastUpdated);
|
|
86
|
+
const threshold = thresholds[scope];
|
|
87
|
+
if (chaptersSince < threshold)
|
|
88
|
+
continue;
|
|
89
|
+
const description = safeString(it.description);
|
|
90
|
+
const label = description ?? it.id;
|
|
91
|
+
const tasks = makeLightTouchTasks({ id: it.id, label, scope, status, chaptersSince });
|
|
92
|
+
dormant.push({
|
|
93
|
+
id: it.id,
|
|
94
|
+
description,
|
|
95
|
+
scope,
|
|
96
|
+
status,
|
|
97
|
+
last_updated_chapter: lastUpdated,
|
|
98
|
+
chapters_since_last_update: chaptersSince,
|
|
99
|
+
dormancy_threshold: threshold,
|
|
100
|
+
...tasks
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
dormant.sort((a, b) => b.chapters_since_last_update - a.chapters_since_last_update || compareStrings(a.scope, b.scope) || compareStrings(a.id, b.id));
|
|
104
|
+
const dormantByScope = { short: 0, medium: 0, long: 0 };
|
|
105
|
+
for (const it of dormant)
|
|
106
|
+
dormantByScope[it.scope] += 1;
|
|
107
|
+
return {
|
|
108
|
+
schema_version: 1,
|
|
109
|
+
generated_at: new Date().toISOString(),
|
|
110
|
+
as_of: { chapter: args.asOfChapter, volume: args.volume },
|
|
111
|
+
platform: args.platform,
|
|
112
|
+
genre_drive_type: args.genreDriveType,
|
|
113
|
+
thresholds,
|
|
114
|
+
dormant_items: dormant,
|
|
115
|
+
counts: { dormant_total: dormant.length, dormant_by_scope: dormantByScope }
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
export async function writeForeshadowVisibilityLogs(args) {
|
|
119
|
+
const dirRel = "logs/foreshadowing";
|
|
120
|
+
const dirAbs = join(args.rootDir, dirRel);
|
|
121
|
+
await ensureDir(dirAbs);
|
|
122
|
+
const latestRel = `${dirRel}/latest.json`;
|
|
123
|
+
const latestAbs = join(args.rootDir, latestRel);
|
|
124
|
+
const result = { latestRel };
|
|
125
|
+
if (args.historyRange) {
|
|
126
|
+
const start = args.historyRange.start;
|
|
127
|
+
const end = args.historyRange.end;
|
|
128
|
+
const historyRel = `${dirRel}/foreshadow-visibility-vol-${pad2(args.report.as_of.volume)}-ch${pad3(start)}-ch${pad3(end)}.json`;
|
|
129
|
+
await writeJsonFile(join(args.rootDir, historyRel), args.report);
|
|
130
|
+
result.historyRel = historyRel;
|
|
131
|
+
}
|
|
132
|
+
const parseLatest = (raw) => {
|
|
133
|
+
if (!isPlainObject(raw))
|
|
134
|
+
return null;
|
|
135
|
+
const obj = raw;
|
|
136
|
+
if (obj.schema_version !== 1)
|
|
137
|
+
return null;
|
|
138
|
+
const asOf = obj.as_of;
|
|
139
|
+
if (!isPlainObject(asOf))
|
|
140
|
+
return null;
|
|
141
|
+
const chapter = asOf.chapter;
|
|
142
|
+
if (typeof chapter !== "number" || !Number.isInteger(chapter) || chapter < 0)
|
|
143
|
+
return null;
|
|
144
|
+
const rawTs = typeof obj.generated_at === "string" ? obj.generated_at : null;
|
|
145
|
+
const generated_at = rawTs && Number.isFinite(Date.parse(rawTs)) ? rawTs : null;
|
|
146
|
+
return { chapter, generated_at };
|
|
147
|
+
};
|
|
148
|
+
const next = { chapter: args.report.as_of.chapter, generated_at: args.report.generated_at };
|
|
149
|
+
let shouldWriteLatest = true;
|
|
150
|
+
if (await pathExists(latestAbs)) {
|
|
151
|
+
try {
|
|
152
|
+
const existing = parseLatest(await readJsonFile(latestAbs));
|
|
153
|
+
if (existing) {
|
|
154
|
+
if (existing.chapter > next.chapter) {
|
|
155
|
+
shouldWriteLatest = false;
|
|
156
|
+
}
|
|
157
|
+
else if (existing.chapter === next.chapter) {
|
|
158
|
+
// If timestamps are comparable, keep the newer one; otherwise, overwrite.
|
|
159
|
+
if (existing.generated_at) {
|
|
160
|
+
const a = Date.parse(existing.generated_at);
|
|
161
|
+
const b = Date.parse(next.generated_at);
|
|
162
|
+
if (Number.isFinite(a) && Number.isFinite(b) && a >= b)
|
|
163
|
+
shouldWriteLatest = false;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
shouldWriteLatest = true;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (shouldWriteLatest) {
|
|
173
|
+
// Atomic replace to avoid partial/corrupted JSON on interruption.
|
|
174
|
+
const tmpAbs = join(dirAbs, `.tmp-foreshadowing-latest-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.json`);
|
|
175
|
+
await writeJsonFile(tmpAbs, args.report);
|
|
176
|
+
try {
|
|
177
|
+
// Re-check right before publish to reduce (not eliminate) races without introducing a lock.
|
|
178
|
+
let stillWrite = true;
|
|
179
|
+
if (await pathExists(latestAbs)) {
|
|
180
|
+
try {
|
|
181
|
+
const existing2 = parseLatest(await readJsonFile(latestAbs));
|
|
182
|
+
if (existing2) {
|
|
183
|
+
if (existing2.chapter > next.chapter) {
|
|
184
|
+
stillWrite = false;
|
|
185
|
+
}
|
|
186
|
+
else if (existing2.chapter === next.chapter && existing2.generated_at) {
|
|
187
|
+
const a = Date.parse(existing2.generated_at);
|
|
188
|
+
const b = Date.parse(next.generated_at);
|
|
189
|
+
if (Number.isFinite(a) && Number.isFinite(b) && a >= b)
|
|
190
|
+
stillWrite = false;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
stillWrite = true;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (stillWrite)
|
|
199
|
+
await rename(tmpAbs, latestAbs);
|
|
200
|
+
}
|
|
201
|
+
finally {
|
|
202
|
+
await rm(tmpAbs, { force: true }).catch(() => { });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return result;
|
|
206
|
+
}
|
|
207
|
+
export async function loadForeshadowGlobalItems(rootDir) {
|
|
208
|
+
const rel = "foreshadowing/global.json";
|
|
209
|
+
const abs = join(rootDir, rel);
|
|
210
|
+
if (!(await pathExists(abs)))
|
|
211
|
+
return [];
|
|
212
|
+
const raw = await readJsonFile(abs);
|
|
213
|
+
return normalizeForeshadowList(raw);
|
|
214
|
+
}
|
package/dist/fs-utils.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { NovelCliError } from "./errors.js";
|
|
4
|
+
export async function pathExists(path) {
|
|
5
|
+
try {
|
|
6
|
+
await stat(path);
|
|
7
|
+
return true;
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export async function ensureDir(path) {
|
|
14
|
+
await mkdir(path, { recursive: true });
|
|
15
|
+
}
|
|
16
|
+
export async function readTextFile(path) {
|
|
17
|
+
try {
|
|
18
|
+
return await readFile(path, "utf8");
|
|
19
|
+
}
|
|
20
|
+
catch (err) {
|
|
21
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
22
|
+
throw new NovelCliError(`Failed to read file: ${path}. ${message}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export async function readJsonFile(path) {
|
|
26
|
+
const raw = await readTextFile(path);
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(raw);
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
32
|
+
throw new NovelCliError(`Invalid JSON: ${path}. ${message}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export async function writeTextFile(path, contents) {
|
|
36
|
+
try {
|
|
37
|
+
await ensureDir(dirname(path));
|
|
38
|
+
await writeFile(path, contents, "utf8");
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
42
|
+
throw new NovelCliError(`Failed to write file: ${path}. ${message}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export async function writeTextFileIfMissing(path, contents) {
|
|
46
|
+
try {
|
|
47
|
+
await ensureDir(dirname(path));
|
|
48
|
+
await writeFile(path, contents, { encoding: "utf8", flag: "wx" });
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
if (err && typeof err === "object" && "code" in err && err.code === "EEXIST")
|
|
52
|
+
return;
|
|
53
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
54
|
+
throw new NovelCliError(`Failed to write file: ${path}. ${message}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export async function writeJsonFile(path, payload) {
|
|
58
|
+
await writeTextFile(path, `${JSON.stringify(payload, null, 2)}\n`);
|
|
59
|
+
}
|
|
60
|
+
export async function removePath(path) {
|
|
61
|
+
try {
|
|
62
|
+
await rm(path, { recursive: true, force: true });
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
66
|
+
throw new NovelCliError(`Failed to remove path: ${path}. ${message}`);
|
|
67
|
+
}
|
|
68
|
+
}
|