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.
Files changed (75) hide show
  1. package/README.md +1 -1
  2. package/agents/chapter-writer.md +43 -14
  3. package/agents/character-weaver.md +7 -1
  4. package/agents/plot-architect.md +20 -7
  5. package/agents/quality-judge.md +199 -20
  6. package/agents/style-analyzer.md +14 -8
  7. package/agents/style-refiner.md +10 -3
  8. package/agents/world-builder.md +8 -1
  9. package/dist/__tests__/agent-prompts-anti-ai-upgrade.test.js +194 -6
  10. package/dist/__tests__/agent-prompts-platform-expansion.test.js +33 -0
  11. package/dist/__tests__/anti-ai-infrastructure.test.js +548 -0
  12. package/dist/__tests__/anti-ai-templates.test.js +2 -2
  13. package/dist/__tests__/canon-status-lifecycle.test.js +481 -0
  14. package/dist/__tests__/commit-gate-decision.test.js +65 -0
  15. package/dist/__tests__/commit-prototype-pollution.test.js +1 -1
  16. package/dist/__tests__/excitement-type-annotation.test.js +240 -0
  17. package/dist/__tests__/excitement-type.test.js +21 -0
  18. package/dist/__tests__/gate-decision.test.js +62 -15
  19. package/dist/__tests__/genre-excitement-mapping.test.js +355 -0
  20. package/dist/__tests__/golden-chapter-gates.test.js +79 -0
  21. package/dist/__tests__/golden-chapter-mini-planning.test.js +485 -0
  22. package/dist/__tests__/helpers/quickstart-mini-planning.js +61 -0
  23. package/dist/__tests__/init.test.js +57 -5
  24. package/dist/__tests__/instructions-platform-expansion.test.js +125 -0
  25. package/dist/__tests__/next-step-gate-decision-routing.test.js +98 -0
  26. package/dist/__tests__/orchestrator-state-write-path.test.js +1 -1
  27. package/dist/__tests__/platform-profile.test.js +57 -1
  28. package/dist/__tests__/quickstart-pipeline.test.js +73 -6
  29. package/dist/__tests__/scoring-weights.test.js +193 -0
  30. package/dist/__tests__/steps-id.test.js +2 -0
  31. package/dist/__tests__/validate-quickstart-prereqs.test.js +2 -0
  32. package/dist/advance.js +27 -2
  33. package/dist/anti-ai-context.js +535 -0
  34. package/dist/cli.js +3 -1
  35. package/dist/commit.js +22 -0
  36. package/dist/excitement-type.js +12 -0
  37. package/dist/gate-decision.js +98 -2
  38. package/dist/golden-chapter-gates.js +143 -0
  39. package/dist/init.js +76 -7
  40. package/dist/instructions.js +552 -6
  41. package/dist/next-step.js +124 -88
  42. package/dist/platform-profile.js +20 -8
  43. package/dist/quickstart-mini-planning.js +30 -0
  44. package/dist/scoring-weights.js +38 -3
  45. package/dist/steps.js +1 -1
  46. package/dist/validate.js +293 -214
  47. package/dist/volume-commit.js +271 -5
  48. package/dist/volume-planning.js +78 -3
  49. package/docs/user/README.md +1 -0
  50. package/docs/user/migration-guide.md +166 -0
  51. package/docs/user/novel-cli.md +4 -3
  52. package/docs/user/quick-start.md +354 -57
  53. package/package.json +1 -1
  54. package/schemas/platform-profile.schema.json +2 -2
  55. package/scripts/lint-blacklist.sh +221 -76
  56. package/scripts/lint-structural.sh +538 -0
  57. package/skills/continue/SKILL.md +6 -0
  58. package/skills/continue/references/context-contracts.md +71 -6
  59. package/skills/continue/references/periodic-maintenance.md +12 -1
  60. package/skills/novel-writing/references/quality-rubric.md +79 -26
  61. package/skills/novel-writing/references/style-guide.md +129 -19
  62. package/skills/start/SKILL.md +23 -3
  63. package/skills/start/references/vol-planning.md +12 -3
  64. package/templates/ai-blacklist.json +1024 -246
  65. package/templates/ai-sentence-patterns.json +167 -0
  66. package/templates/genre-excitement-map.json +48 -0
  67. package/templates/genre-golden-standards.json +80 -0
  68. package/templates/genre-weight-profiles.json +15 -0
  69. package/templates/golden-chapter-gates.json +230 -0
  70. package/templates/novel-ask/example.question.json +3 -2
  71. package/templates/platform-profile.json +141 -1
  72. package/templates/platforms/fanqie.md +35 -0
  73. package/templates/platforms/jinjiang.md +35 -0
  74. package/templates/platforms/qidian.md +35 -0
  75. package/templates/style-profile-template.json +3 -0
@@ -38,10 +38,10 @@ export function detectHighConfidenceViolation(evalRaw) {
38
38
  return { has_high_confidence_violation: hardChecks.length > 0, high_confidence_violations: hardChecks };
39
39
  }
40
40
  export function computeGateDecision(args) {
41
- const maxRevisions = typeof args.max_revisions === "number" && Number.isInteger(args.max_revisions) && args.max_revisions >= 0 ? args.max_revisions : 2;
41
+ const maxRevisions = normalizeMaxRevisions(args.max_revisions);
42
42
  if (args.force_pass)
43
43
  return "force_passed";
44
- if (args.has_high_confidence_violation) {
44
+ if (args.has_high_confidence_violation || args.has_golden_chapter_gate_failure) {
45
45
  return args.revision_count >= maxRevisions ? "pause_for_user" : "revise";
46
46
  }
47
47
  const score = args.overall_final;
@@ -57,3 +57,99 @@ export function computeGateDecision(args) {
57
57
  return "pause_for_user";
58
58
  return "pause_for_user_force_rewrite";
59
59
  }
60
+ function normalizeMaxRevisions(value) {
61
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : 2;
62
+ }
63
+ export function normalizeGateRevisionCount(value) {
64
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : 0;
65
+ }
66
+ export function normalizeGateMaxRevisions(value) {
67
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : null;
68
+ }
69
+ function isGoldenChapterGateFailureStatus(value) {
70
+ return value === "fail" || value === "failed" || value === "violation";
71
+ }
72
+ export function detectGoldenChapterGateFailure(evalRaw) {
73
+ if (!isPlainObject(evalRaw))
74
+ return { has_golden_chapter_gate_failure: false, failed_checks: [] };
75
+ const evalObj = evalRaw;
76
+ const raw = evalObj.golden_chapter_gates;
77
+ if (!isPlainObject(raw))
78
+ return { has_golden_chapter_gate_failure: false, failed_checks: [] };
79
+ const gates = raw;
80
+ if (gates.activated === false)
81
+ return { has_golden_chapter_gate_failure: false, failed_checks: [] };
82
+ const failedChecks = [];
83
+ const seenIds = new Set();
84
+ const checksRaw = gates.checks;
85
+ if (Array.isArray(checksRaw)) {
86
+ for (const item of checksRaw) {
87
+ if (!isPlainObject(item))
88
+ continue;
89
+ const check = item;
90
+ if (!isGoldenChapterGateFailureStatus(check.status))
91
+ continue;
92
+ const id = typeof check.id === "string" && check.id.trim().length > 0 ? check.id.trim() : null;
93
+ if (id) {
94
+ if (seenIds.has(id))
95
+ continue;
96
+ seenIds.add(id);
97
+ failedChecks.push({ ...check, id });
98
+ continue;
99
+ }
100
+ failedChecks.push(check);
101
+ }
102
+ }
103
+ const failedGateIdsRaw = gates.failed_gate_ids;
104
+ if (Array.isArray(failedGateIdsRaw)) {
105
+ for (const item of failedGateIdsRaw) {
106
+ if (typeof item !== "string" || item.trim().length === 0)
107
+ continue;
108
+ const id = item.trim();
109
+ if (seenIds.has(id))
110
+ continue;
111
+ seenIds.add(id);
112
+ failedChecks.push({ id, status: "fail" });
113
+ }
114
+ }
115
+ const hasFailure = gates.passed === false || failedChecks.length > 0;
116
+ return { has_golden_chapter_gate_failure: hasFailure, failed_checks: failedChecks };
117
+ }
118
+ export function evaluateGateDecisionFromEval(args) {
119
+ if (!isPlainObject(args.evalRaw))
120
+ return { ok: false, reason: "eval_invalid" };
121
+ const evalObj = args.evalRaw;
122
+ const overall = typeof evalObj.overall_final === "number"
123
+ ? evalObj.overall_final
124
+ : typeof evalObj.overall === "number"
125
+ ? evalObj.overall
126
+ : null;
127
+ if (overall === null || !Number.isFinite(overall)) {
128
+ return { ok: false, reason: "eval_missing_overall" };
129
+ }
130
+ const maxRevisions = normalizeGateMaxRevisions(args.max_revisions);
131
+ const violation = detectHighConfidenceViolation(evalObj);
132
+ const goldenGateFailure = detectGoldenChapterGateFailure(evalObj);
133
+ const decision = computeGateDecision({
134
+ overall_final: overall,
135
+ revision_count: args.revision_count,
136
+ has_high_confidence_violation: violation.has_high_confidence_violation,
137
+ has_golden_chapter_gate_failure: goldenGateFailure.has_golden_chapter_gate_failure,
138
+ ...(maxRevisions === null ? {} : { max_revisions: maxRevisions }),
139
+ ...(args.force_pass ? { force_pass: true } : {})
140
+ });
141
+ return {
142
+ ok: true,
143
+ gate: {
144
+ overall_final: overall,
145
+ decision,
146
+ revision_count: args.revision_count,
147
+ max_revisions: maxRevisions,
148
+ has_high_confidence_violation: violation.has_high_confidence_violation,
149
+ high_confidence_violations: violation.high_confidence_violations,
150
+ has_golden_chapter_gate_failure: goldenGateFailure.has_golden_chapter_gate_failure,
151
+ golden_chapter_gate_failures: goldenGateFailure.failed_checks,
152
+ recommendation: typeof evalObj.recommendation === "string" ? evalObj.recommendation : null
153
+ }
154
+ };
155
+ }
@@ -0,0 +1,143 @@
1
+ import { join } from "node:path";
2
+ import { NovelCliError } from "./errors.js";
3
+ import { pathExists, readJsonFile } from "./fs-utils.js";
4
+ import { CANONICAL_PLATFORM_IDS, canonicalPlatformId } from "./platform-profile.js";
5
+ import { isPlainObject } from "./type-guards.js";
6
+ const VALID_THRESHOLD_OPERATORS = ["<", "<=", ">", ">=", "==", "!="];
7
+ function requireString(value, file, field) {
8
+ if (typeof value !== "string" || value.trim().length === 0) {
9
+ throw new NovelCliError(`Invalid ${file}: '${field}' must be a non-empty string.`, 2);
10
+ }
11
+ return value.trim();
12
+ }
13
+ function requireOptionalStringArray(value, file, field) {
14
+ if (value === undefined)
15
+ return undefined;
16
+ if (!Array.isArray(value) || !value.every((item) => typeof item === "string" && item.trim().length > 0)) {
17
+ throw new NovelCliError(`Invalid ${file}: '${field}' must be a string array.`, 2);
18
+ }
19
+ return value.map((item) => item.trim());
20
+ }
21
+ function parseRule(raw, file, field) {
22
+ if (!isPlainObject(raw))
23
+ throw new NovelCliError(`Invalid ${file}: '${field}' must be an object.`, 2);
24
+ const obj = raw;
25
+ const out = {
26
+ id: requireString(obj.id, file, `${field}.id`),
27
+ requirement: requireString(obj.requirement, file, `${field}.requirement`)
28
+ };
29
+ if (obj.evaluation_hint !== undefined) {
30
+ out.evaluation_hint = requireString(obj.evaluation_hint, file, `${field}.evaluation_hint`);
31
+ }
32
+ if (obj.threshold !== undefined) {
33
+ if (!isPlainObject(obj.threshold))
34
+ throw new NovelCliError(`Invalid ${file}: '${field}.threshold' must be an object.`, 2);
35
+ const threshold = obj.threshold;
36
+ const value = threshold.value;
37
+ if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") {
38
+ throw new NovelCliError(`Invalid ${file}: '${field}.threshold.value' must be string|number|boolean.`, 2);
39
+ }
40
+ const operator = requireString(threshold.operator, file, `${field}.threshold.operator`);
41
+ if (!VALID_THRESHOLD_OPERATORS.includes(operator)) {
42
+ throw new NovelCliError(`Invalid ${file}: '${field}.threshold.operator' must be one of: ${VALID_THRESHOLD_OPERATORS.join(", ")}.`, 2);
43
+ }
44
+ out.threshold = {
45
+ metric: requireString(threshold.metric, file, `${field}.threshold.metric`),
46
+ operator,
47
+ value
48
+ };
49
+ }
50
+ const allowed_values = requireOptionalStringArray(obj.allowed_values, file, `${field}.allowed_values`);
51
+ if (allowed_values)
52
+ out.allowed_values = allowed_values;
53
+ return out;
54
+ }
55
+ function parseChapterConfig(raw, file, field) {
56
+ if (!isPlainObject(raw))
57
+ throw new NovelCliError(`Invalid ${file}: '${field}' must be an object.`, 2);
58
+ const obj = raw;
59
+ const gatesRaw = obj.gates;
60
+ if (!Array.isArray(gatesRaw) || gatesRaw.length === 0) {
61
+ throw new NovelCliError(`Invalid ${file}: '${field}.gates' must be a non-empty array.`, 2);
62
+ }
63
+ const gates = gatesRaw.map((item, index) => parseRule(item, file, `${field}.gates[${index}]`));
64
+ const notes = requireOptionalStringArray(obj.notes, file, `${field}.notes`);
65
+ return notes ? { gates, notes } : { gates };
66
+ }
67
+ export function parseGoldenChapterGates(raw, file) {
68
+ if (!isPlainObject(raw))
69
+ throw new NovelCliError(`Invalid ${file}: expected a JSON object.`, 2);
70
+ const obj = raw;
71
+ if (obj.schema_version !== 1)
72
+ throw new NovelCliError(`Invalid ${file}: 'schema_version' must be 1.`, 2);
73
+ if (!isPlainObject(obj.platforms))
74
+ throw new NovelCliError(`Invalid ${file}: 'platforms' must be an object.`, 2);
75
+ const platformsRaw = obj.platforms;
76
+ const platforms = {};
77
+ for (const platformId of CANONICAL_PLATFORM_IDS) {
78
+ const platformRaw = platformsRaw[platformId];
79
+ if (!isPlainObject(platformRaw)) {
80
+ throw new NovelCliError(`Invalid ${file}: missing 'platforms.${platformId}' object.`, 2);
81
+ }
82
+ const platformObj = platformRaw;
83
+ if (!isPlainObject(platformObj.chapters)) {
84
+ throw new NovelCliError(`Invalid ${file}: 'platforms.${platformId}.chapters' must be an object.`, 2);
85
+ }
86
+ const chaptersRaw = platformObj.chapters;
87
+ const chapters = {};
88
+ for (const chapter of ["1", "2", "3"]) {
89
+ chapters[chapter] = parseChapterConfig(chaptersRaw[chapter], file, `platforms.${platformId}.chapters.${chapter}`);
90
+ }
91
+ platforms[platformId] = { chapters };
92
+ }
93
+ if (!Array.isArray(obj.invalid_combinations)) {
94
+ throw new NovelCliError(`Invalid ${file}: 'invalid_combinations' must be an array.`, 2);
95
+ }
96
+ const invalid_combinations = obj.invalid_combinations.map((item, index) => {
97
+ if (!isPlainObject(item)) {
98
+ throw new NovelCliError(`Invalid ${file}: 'invalid_combinations[${index}]' must be an object.`, 2);
99
+ }
100
+ const entry = item;
101
+ const platform = requireString(entry.platform, file, `invalid_combinations[${index}].platform`);
102
+ if (!CANONICAL_PLATFORM_IDS.includes(platform)) {
103
+ throw new NovelCliError(`Invalid ${file}: 'invalid_combinations[${index}].platform' must be one of ${CANONICAL_PLATFORM_IDS.join(", ")}.`, 2);
104
+ }
105
+ return {
106
+ genre: requireString(entry.genre, file, `invalid_combinations[${index}].genre`),
107
+ platform: platform,
108
+ warning: requireString(entry.warning, file, `invalid_combinations[${index}].warning`)
109
+ };
110
+ });
111
+ const out = { schema_version: 1, invalid_combinations, platforms };
112
+ if (typeof obj.description === "string" && obj.description.trim().length > 0)
113
+ out.description = obj.description.trim();
114
+ return out;
115
+ }
116
+ export async function loadGoldenChapterGates(rootDir) {
117
+ const relPath = "golden-chapter-gates.json";
118
+ const absPath = join(rootDir, relPath);
119
+ if (!(await pathExists(absPath)))
120
+ return null;
121
+ const raw = await readJsonFile(absPath);
122
+ return { relPath, config: parseGoldenChapterGates(raw, relPath) };
123
+ }
124
+ export function selectGoldenChapterGatesForPlatform(args) {
125
+ if (!Number.isInteger(args.chapter) || args.chapter < 1 || args.chapter > 3)
126
+ return null;
127
+ const platform = canonicalPlatformId(args.platformId);
128
+ const platformConfig = args.config.platforms[platform];
129
+ if (!platformConfig)
130
+ return null;
131
+ const current_chapter = platformConfig.chapters[String(args.chapter)];
132
+ if (!current_chapter)
133
+ return null;
134
+ return {
135
+ platform,
136
+ chapter: args.chapter,
137
+ current_chapter,
138
+ chapters: platformConfig.chapters,
139
+ invalid_combination_warnings: args.config.invalid_combinations
140
+ .filter((item) => item.platform === platform)
141
+ .map((item) => ({ genre: item.genre, warning: item.warning }))
142
+ };
143
+ }
package/dist/init.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { stat } from "node:fs/promises";
2
2
  import { join, resolve } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
+ import { canonicalPlatformId } from "./platform-profile.js";
4
5
  import { createDefaultCheckpoint } from "./checkpoint.js";
5
6
  import { NovelCliError } from "./errors.js";
6
7
  import { ensureDir, pathExists, readJsonFile, readTextFile, writeJsonFile, writeTextFile } from "./fs-utils.js";
@@ -14,9 +15,9 @@ export function resolveInitRootDir(args) {
14
15
  return resolve(cwdAbs, args.projectOverride);
15
16
  }
16
17
  export function normalizePlatformId(value) {
17
- if (value === "qidian" || value === "tomato")
18
+ if (value === "qidian" || value === "tomato" || value === "fanqie" || value === "jinjiang")
18
19
  return value;
19
- throw new NovelCliError(`Invalid --platform: ${String(value)} (expected qidian|tomato).`, 2);
20
+ throw new NovelCliError(`Invalid --platform: ${String(value)} (expected qidian|tomato|fanqie|jinjiang).`, 2);
20
21
  }
21
22
  function moduleRootDir() {
22
23
  // src/init.ts → <repo_root>; dist/init.js → <package_root>
@@ -40,7 +41,21 @@ async function ensureRootIsDirectory(absPath) {
40
41
  }
41
42
  async function writeIfMissingOrForce(args) {
42
43
  const abs = join(args.rootDir, args.relPath);
43
- const exists = await pathExists(abs);
44
+ let exists = false;
45
+ try {
46
+ const current = await stat(abs);
47
+ if (!current.isFile()) {
48
+ throw new NovelCliError(`Cannot initialize ${args.relPath}: existing path is not a file. Remove it or choose a different project root.`, 2);
49
+ }
50
+ exists = true;
51
+ }
52
+ catch (err) {
53
+ if (err instanceof NovelCliError)
54
+ throw err;
55
+ const code = typeof err === "object" && err !== null && "code" in err ? String(err.code) : null;
56
+ if (code !== "ENOENT")
57
+ throw err;
58
+ }
44
59
  if (exists && !args.force) {
45
60
  args.result.skipped.push(args.relPath);
46
61
  return;
@@ -93,9 +108,32 @@ async function loadPlatformProfileTemplate(platform) {
93
108
  }
94
109
  return selected;
95
110
  }
111
+ async function tryLoadPlatformWritingGuide(platform) {
112
+ const canonicalPlatform = canonicalPlatformId(platform);
113
+ const relPath = `platforms/${canonicalPlatform}.md`;
114
+ const absPath = join(TEMPLATE_DIR, relPath);
115
+ if (!(await pathExists(absPath))) {
116
+ return {
117
+ text: null,
118
+ warning: `Missing optional platform writing guide template: templates/${relPath}. Init continued without platform-writing-guide.md.`
119
+ };
120
+ }
121
+ try {
122
+ return { text: await readTextFile(absPath), warning: null };
123
+ }
124
+ catch (err) {
125
+ const message = err instanceof Error ? err.message : String(err);
126
+ return {
127
+ text: null,
128
+ warning: `Failed to read optional platform writing guide template: templates/${relPath}. ${message}`
129
+ };
130
+ }
131
+ }
96
132
  const DEFAULT_TEMPLATES = [
97
133
  { relPath: "brief.md", templateName: "brief-template.md", kind: "text" },
98
134
  { relPath: "style-profile.json", templateName: "style-profile-template.json", kind: "json" },
135
+ { relPath: "genre-excitement-map.json", templateName: "genre-excitement-map.json", kind: "json" },
136
+ { relPath: "genre-golden-standards.json", templateName: "genre-golden-standards.json", kind: "json" },
99
137
  { relPath: "ai-blacklist.json", templateName: "ai-blacklist.json", kind: "json" },
100
138
  { relPath: "web-novel-cliche-lint.json", templateName: "web-novel-cliche-lint.json", kind: "json" }
101
139
  ];
@@ -120,7 +158,8 @@ export async function initProject(args) {
120
158
  ensuredDirs: [],
121
159
  created: [],
122
160
  overwritten: [],
123
- skipped: []
161
+ skipped: [],
162
+ warnings: []
124
163
  };
125
164
  await ensureRootIsDirectory(args.rootDir);
126
165
  for (const relDir of STAGING_SUBDIRS) {
@@ -138,9 +177,19 @@ export async function initProject(args) {
138
177
  });
139
178
  if (!minimal) {
140
179
  for (const tmpl of DEFAULT_TEMPLATES) {
141
- const contents = tmpl.kind === "text"
142
- ? { kind: "text", text: await loadTemplateText(tmpl.templateName) }
143
- : { kind: "json", json: await loadTemplateJson(tmpl.templateName) };
180
+ let contents;
181
+ if (tmpl.kind === "text") {
182
+ contents = { kind: "text", text: await loadTemplateText(tmpl.templateName) };
183
+ }
184
+ else {
185
+ const json = await loadTemplateJson(tmpl.templateName);
186
+ if (tmpl.relPath === "style-profile.json" && platform) {
187
+ contents = { kind: "json", json: { ...json, platform } };
188
+ }
189
+ else {
190
+ contents = { kind: "json", json };
191
+ }
192
+ }
144
193
  await writeIfMissingOrForce({ rootDir: args.rootDir, relPath: tmpl.relPath, contents, force, result });
145
194
  }
146
195
  }
@@ -161,6 +210,26 @@ export async function initProject(args) {
161
210
  force,
162
211
  result
163
212
  });
213
+ await writeIfMissingOrForce({
214
+ rootDir: args.rootDir,
215
+ relPath: "golden-chapter-gates.json",
216
+ contents: { kind: "json", json: await loadTemplateJson("golden-chapter-gates.json") },
217
+ force,
218
+ result
219
+ });
220
+ const guide = await tryLoadPlatformWritingGuide(platform);
221
+ if (guide.text !== null) {
222
+ await writeIfMissingOrForce({
223
+ rootDir: args.rootDir,
224
+ relPath: "platform-writing-guide.md",
225
+ contents: { kind: "text", text: guide.text },
226
+ force,
227
+ result
228
+ });
229
+ }
230
+ else if (guide.warning) {
231
+ result.warnings.push(guide.warning);
232
+ }
164
233
  }
165
234
  return result;
166
235
  }