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,132 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdir, mkdtemp, 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 { parsePlatformProfile } from "../platform-profile.js";
|
|
7
|
+
import { computeNamingReport, writeNamingLintLogs } from "../naming-lint.js";
|
|
8
|
+
function makeProfileRaw(extra) {
|
|
9
|
+
return {
|
|
10
|
+
schema_version: 1,
|
|
11
|
+
platform: "qidian",
|
|
12
|
+
created_at: "2026-01-01T00:00:00Z",
|
|
13
|
+
word_count: { target_min: 1, target_max: 2, hard_min: 1, hard_max: 2 },
|
|
14
|
+
hook_policy: { required: false, min_strength: 3, allowed_types: ["question"], fix_strategy: "hook-fix" },
|
|
15
|
+
info_load: { max_new_entities_per_chapter: 0, max_unknown_entities_per_chapter: 0, max_new_terms_per_1k_words: 0 },
|
|
16
|
+
compliance: { banned_words: [], duplicate_name_policy: "warn" },
|
|
17
|
+
scoring: { genre_drive_type: "plot", weight_profile_id: "plot:v1" },
|
|
18
|
+
...extra
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
function makeNamingPolicy(overrides = {}) {
|
|
22
|
+
return {
|
|
23
|
+
enabled: true,
|
|
24
|
+
near_duplicate_threshold: 0.88,
|
|
25
|
+
blocking_conflict_types: ["duplicate"],
|
|
26
|
+
exemptions: {},
|
|
27
|
+
...overrides
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
async function writeCharacterProfile(rootDir, slug, payload) {
|
|
31
|
+
const dirAbs = join(rootDir, "characters", "active");
|
|
32
|
+
await mkdir(dirAbs, { recursive: true });
|
|
33
|
+
await writeFile(join(dirAbs, `${slug}.json`), `${JSON.stringify({ id: slug, ...payload }, null, 2)}\n`, "utf8");
|
|
34
|
+
}
|
|
35
|
+
test("computeNamingReport skips when naming policy is missing/disabled", async () => {
|
|
36
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-naming-skip-test-"));
|
|
37
|
+
const profile = parsePlatformProfile(makeProfileRaw({ naming: null }), "platform-profile.json");
|
|
38
|
+
const report = await computeNamingReport({
|
|
39
|
+
rootDir,
|
|
40
|
+
chapter: 1,
|
|
41
|
+
chapterText: "# T\n正文\n",
|
|
42
|
+
platformProfile: profile
|
|
43
|
+
});
|
|
44
|
+
assert.equal(report.status, "skipped");
|
|
45
|
+
assert.equal(report.issues.length, 0);
|
|
46
|
+
});
|
|
47
|
+
test("computeNamingReport flags duplicate canonical display_name as blocking when configured", async () => {
|
|
48
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-naming-duplicate-test-"));
|
|
49
|
+
const profile = parsePlatformProfile(makeProfileRaw({ naming: makeNamingPolicy({ blocking_conflict_types: ["duplicate"] }) }), "platform-profile.json");
|
|
50
|
+
await writeCharacterProfile(rootDir, "lin-feng", { display_name: "林枫" });
|
|
51
|
+
await writeCharacterProfile(rootDir, "lin-feng-2", { display_name: "林枫" });
|
|
52
|
+
const report = await computeNamingReport({
|
|
53
|
+
rootDir,
|
|
54
|
+
chapter: 1,
|
|
55
|
+
chapterText: "# T\n正文\n",
|
|
56
|
+
platformProfile: profile
|
|
57
|
+
});
|
|
58
|
+
assert.equal(report.has_blocking_issues, true);
|
|
59
|
+
assert.equal(report.status, "violation");
|
|
60
|
+
assert.ok(report.issues.some((i) => i.id === "naming.duplicate_display_name" && i.severity === "hard"));
|
|
61
|
+
});
|
|
62
|
+
test("computeNamingReport flags near-duplicate names based on similarity threshold", async () => {
|
|
63
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-naming-near-dup-test-"));
|
|
64
|
+
const profile = parsePlatformProfile(makeProfileRaw({ naming: makeNamingPolicy({ blocking_conflict_types: ["duplicate"] }) }), "platform-profile.json");
|
|
65
|
+
await writeCharacterProfile(rootDir, "lin-feng", { display_name: "林枫" });
|
|
66
|
+
await writeCharacterProfile(rootDir, "lin-feng-2", { display_name: "林峰" });
|
|
67
|
+
const report = await computeNamingReport({
|
|
68
|
+
rootDir,
|
|
69
|
+
chapter: 1,
|
|
70
|
+
chapterText: "# T\n正文\n",
|
|
71
|
+
platformProfile: profile
|
|
72
|
+
});
|
|
73
|
+
const near = report.issues.find((i) => i.id === "naming.near_duplicate");
|
|
74
|
+
assert.ok(near, "expected naming.near_duplicate issue");
|
|
75
|
+
assert.equal(near.severity, "soft");
|
|
76
|
+
assert.ok(typeof near.similarity === "number" && near.similarity >= 0.88);
|
|
77
|
+
assert.equal(report.has_blocking_issues, false);
|
|
78
|
+
assert.equal(report.status, "warn");
|
|
79
|
+
});
|
|
80
|
+
test("computeNamingReport flags alias collision when alias matches another character's canonical name", async () => {
|
|
81
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-naming-alias-collision-test-"));
|
|
82
|
+
const profile = parsePlatformProfile(makeProfileRaw({ naming: makeNamingPolicy({ blocking_conflict_types: ["alias_collision"] }) }), "platform-profile.json");
|
|
83
|
+
await writeCharacterProfile(rootDir, "lin-feng", { display_name: "林枫", aliases: ["小枫"] });
|
|
84
|
+
await writeCharacterProfile(rootDir, "xiao-feng", { display_name: "小枫" });
|
|
85
|
+
const report = await computeNamingReport({
|
|
86
|
+
rootDir,
|
|
87
|
+
chapter: 1,
|
|
88
|
+
chapterText: "# T\n正文\n",
|
|
89
|
+
platformProfile: profile
|
|
90
|
+
});
|
|
91
|
+
assert.equal(report.has_blocking_issues, true);
|
|
92
|
+
assert.ok(report.issues.some((i) => i.id === "naming.alias_collision" && i.severity === "hard"));
|
|
93
|
+
});
|
|
94
|
+
test("computeNamingReport uses NER index to warn on confusing unknown character-like entities", async () => {
|
|
95
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-naming-ner-confusion-test-"));
|
|
96
|
+
const profile = parsePlatformProfile(makeProfileRaw({ naming: makeNamingPolicy() }), "platform-profile.json");
|
|
97
|
+
await writeCharacterProfile(rootDir, "lin-feng", { display_name: "林枫" });
|
|
98
|
+
const current_index = new Map();
|
|
99
|
+
current_index.set("林峰", { category: "character", evidence: "L1: 林峰走了进来。" });
|
|
100
|
+
const infoLoadNer = {
|
|
101
|
+
status: "pass",
|
|
102
|
+
chapter_fingerprint: null,
|
|
103
|
+
current_index,
|
|
104
|
+
recent_texts: new Set()
|
|
105
|
+
};
|
|
106
|
+
const report = await computeNamingReport({
|
|
107
|
+
rootDir,
|
|
108
|
+
chapter: 1,
|
|
109
|
+
chapterText: "# T\n林峰走了进来。\n",
|
|
110
|
+
platformProfile: profile,
|
|
111
|
+
infoLoadNer
|
|
112
|
+
});
|
|
113
|
+
assert.equal(report.has_blocking_issues, false);
|
|
114
|
+
assert.equal(report.status, "warn");
|
|
115
|
+
assert.ok(report.issues.some((i) => i.id === "naming.unknown_entity_confusion" && i.severity === "warn"));
|
|
116
|
+
});
|
|
117
|
+
test("writeNamingLintLogs writes history under naming-report-chapter-*.json", async () => {
|
|
118
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-naming-logs-test-"));
|
|
119
|
+
const report = {
|
|
120
|
+
schema_version: 1,
|
|
121
|
+
generated_at: "2026-01-01T00:00:00.000Z",
|
|
122
|
+
scope: { chapter: 1 },
|
|
123
|
+
policy: null,
|
|
124
|
+
registry: { total_characters: 0, total_names: 0 },
|
|
125
|
+
status: "pass",
|
|
126
|
+
issues: [],
|
|
127
|
+
has_blocking_issues: false
|
|
128
|
+
};
|
|
129
|
+
const out = await writeNamingLintLogs({ rootDir, chapter: 1, report });
|
|
130
|
+
assert.equal(out.latestRel, "logs/naming/latest.json");
|
|
131
|
+
assert.equal(out.historyRel, "logs/naming/naming-report-chapter-001.json");
|
|
132
|
+
});
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import { buildInstructionPacket } from "../instructions.js";
|
|
7
|
+
async function writeText(absPath, contents) {
|
|
8
|
+
await mkdir(dirname(absPath), { recursive: true });
|
|
9
|
+
await writeFile(absPath, contents, "utf8");
|
|
10
|
+
}
|
|
11
|
+
async function writeJson(absPath, payload) {
|
|
12
|
+
await writeText(absPath, `${JSON.stringify(payload, null, 2)}\n`);
|
|
13
|
+
}
|
|
14
|
+
test("buildInstructionPacket injects compact narrative health summaries into draft/refine packets (best-effort)", async () => {
|
|
15
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-narrative-health-injection-"));
|
|
16
|
+
const longSummary = `${"x".repeat(238)}😀${"y".repeat(20)}`;
|
|
17
|
+
await writeJson(join(rootDir, "logs/engagement/latest.json"), {
|
|
18
|
+
schema_version: 1,
|
|
19
|
+
generated_at: "2026-03-03T00:00:00.000Z",
|
|
20
|
+
as_of: { chapter: 10, volume: 1 },
|
|
21
|
+
scope: { volume: 1, chapter_start: 1, chapter_end: 10 },
|
|
22
|
+
metrics_stream_path: "engagement-metrics.jsonl",
|
|
23
|
+
metrics: [],
|
|
24
|
+
stats: {
|
|
25
|
+
chapters: 10,
|
|
26
|
+
avg_word_count: 3000,
|
|
27
|
+
avg_plot_progression_beats: 2,
|
|
28
|
+
avg_conflict_intensity: 3,
|
|
29
|
+
avg_payoff_score: 2,
|
|
30
|
+
avg_new_info_load_score: 3
|
|
31
|
+
},
|
|
32
|
+
issues: Array.from({ length: 7 }).map((_, i) => ({
|
|
33
|
+
id: `engagement.issue.${i + 1}`,
|
|
34
|
+
severity: "warn",
|
|
35
|
+
summary: i === 0 ? longSummary : `Issue ${i + 1}`,
|
|
36
|
+
suggestion: "Add a small reveal or reward beat in the next chapter."
|
|
37
|
+
})),
|
|
38
|
+
has_blocking_issues: false
|
|
39
|
+
});
|
|
40
|
+
await writeJson(join(rootDir, "logs/promises/latest.json"), {
|
|
41
|
+
schema_version: 1,
|
|
42
|
+
generated_at: "2026-03-03T00:00:00.000Z",
|
|
43
|
+
as_of: { chapter: 10, volume: 1 },
|
|
44
|
+
scope: { volume: 1, chapter_start: 1, chapter_end: 10 },
|
|
45
|
+
ledger_path: "promise-ledger.json",
|
|
46
|
+
policy: { dormancy_threshold_chapters: 12 },
|
|
47
|
+
stats: {
|
|
48
|
+
total_promises: 2,
|
|
49
|
+
promised_total: 2,
|
|
50
|
+
advanced_total: 0,
|
|
51
|
+
delivered_total: 0,
|
|
52
|
+
open_total: 2,
|
|
53
|
+
dormant_total: 1
|
|
54
|
+
},
|
|
55
|
+
dormant_promises: Array.from({ length: 7 }).map((_, i) => ({
|
|
56
|
+
id: `promise.${i + 1}`,
|
|
57
|
+
type: "core_mystery",
|
|
58
|
+
promise_text: `承诺 ${i + 1}`,
|
|
59
|
+
status: "promised",
|
|
60
|
+
introduced_chapter: 1,
|
|
61
|
+
last_touched_chapter: 1,
|
|
62
|
+
chapters_since_last_touch: i,
|
|
63
|
+
dormancy_threshold_chapters: 12,
|
|
64
|
+
suggestion: "轻触谜团:加入一个微小线索(不要揭示答案)。"
|
|
65
|
+
})),
|
|
66
|
+
issues: [
|
|
67
|
+
{
|
|
68
|
+
id: "promise_ledger.dormancy.dormant_promises",
|
|
69
|
+
severity: "warn",
|
|
70
|
+
summary: "Dormant promises detected.",
|
|
71
|
+
suggestion: "Use light-touch reminders to reduce perceived stalling."
|
|
72
|
+
}
|
|
73
|
+
],
|
|
74
|
+
has_blocking_issues: false
|
|
75
|
+
});
|
|
76
|
+
await writeText(join(rootDir, "staging/chapters/chapter-001.md"), `# 第1章\n\n(占位)\n`);
|
|
77
|
+
const checkpoint = { last_completed_chapter: 10, current_volume: 1 };
|
|
78
|
+
const draftOut = (await buildInstructionPacket({
|
|
79
|
+
rootDir,
|
|
80
|
+
checkpoint,
|
|
81
|
+
step: { kind: "chapter", chapter: 1, stage: "draft" },
|
|
82
|
+
embedMode: null,
|
|
83
|
+
writeManifest: false
|
|
84
|
+
}));
|
|
85
|
+
const draftInline = draftOut.packet.manifest.inline;
|
|
86
|
+
assert.equal(typeof draftInline.engagement_report_summary, "object");
|
|
87
|
+
assert.equal(typeof draftInline.promise_ledger_report_summary, "object");
|
|
88
|
+
assert.equal(draftInline.engagement_report_summary.issues.length, 5);
|
|
89
|
+
assert.equal(draftInline.promise_ledger_report_summary.dormant_promises.length, 5);
|
|
90
|
+
assert.ok(String(draftInline.engagement_report_summary.issues[0]?.summary ?? "").endsWith("…"));
|
|
91
|
+
const truncated = String(draftInline.engagement_report_summary.issues[0]?.summary ?? "");
|
|
92
|
+
const lastBeforeEllipsis = truncated.charCodeAt(Math.max(0, truncated.length - 2));
|
|
93
|
+
assert.ok(lastBeforeEllipsis < 0xd800 || lastBeforeEllipsis > 0xdbff);
|
|
94
|
+
assert.equal(draftOut.packet.manifest.paths.engagement_report_latest, "logs/engagement/latest.json");
|
|
95
|
+
assert.equal(draftOut.packet.manifest.paths.promise_ledger_report_latest, "logs/promises/latest.json");
|
|
96
|
+
const refineOut = (await buildInstructionPacket({
|
|
97
|
+
rootDir,
|
|
98
|
+
checkpoint,
|
|
99
|
+
step: { kind: "chapter", chapter: 1, stage: "refine" },
|
|
100
|
+
embedMode: null,
|
|
101
|
+
writeManifest: false
|
|
102
|
+
}));
|
|
103
|
+
const refineInline = refineOut.packet.manifest.inline;
|
|
104
|
+
assert.equal(typeof refineInline.engagement_report_summary, "object");
|
|
105
|
+
assert.equal(typeof refineInline.promise_ledger_report_summary, "object");
|
|
106
|
+
assert.equal(refineInline.engagement_report_summary.issues.length, 5);
|
|
107
|
+
assert.equal(refineInline.promise_ledger_report_summary.dormant_promises.length, 5);
|
|
108
|
+
assert.equal(refineOut.packet.manifest.paths.engagement_report_latest, "logs/engagement/latest.json");
|
|
109
|
+
assert.equal(refineOut.packet.manifest.paths.promise_ledger_report_latest, "logs/promises/latest.json");
|
|
110
|
+
});
|
|
111
|
+
test("buildInstructionPacket marks degraded when latest reports exist but are invalid", async () => {
|
|
112
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-narrative-health-injection-degraded-"));
|
|
113
|
+
// Engagement latest exists but is invalid JSON.
|
|
114
|
+
await writeText(join(rootDir, "logs/engagement/latest.json"), "{");
|
|
115
|
+
// Promise latest is valid.
|
|
116
|
+
await writeJson(join(rootDir, "logs/promises/latest.json"), {
|
|
117
|
+
schema_version: 1,
|
|
118
|
+
generated_at: "2026-03-03T00:00:00.000Z",
|
|
119
|
+
as_of: { chapter: 10, volume: 1 },
|
|
120
|
+
scope: { volume: 1, chapter_start: 1, chapter_end: 10 },
|
|
121
|
+
ledger_path: "promise-ledger.json",
|
|
122
|
+
policy: { dormancy_threshold_chapters: 12 },
|
|
123
|
+
stats: { total_promises: 0, promised_total: 0, advanced_total: 0, delivered_total: 0, open_total: 0, dormant_total: 0 },
|
|
124
|
+
dormant_promises: [],
|
|
125
|
+
issues: [],
|
|
126
|
+
has_blocking_issues: false
|
|
127
|
+
});
|
|
128
|
+
await writeText(join(rootDir, "staging/chapters/chapter-001.md"), `# 第1章\n\n(占位)\n`);
|
|
129
|
+
const checkpoint = { last_completed_chapter: 10, current_volume: 1 };
|
|
130
|
+
const out = (await buildInstructionPacket({
|
|
131
|
+
rootDir,
|
|
132
|
+
checkpoint,
|
|
133
|
+
step: { kind: "chapter", chapter: 1, stage: "draft" },
|
|
134
|
+
embedMode: null,
|
|
135
|
+
writeManifest: false
|
|
136
|
+
}));
|
|
137
|
+
const inline = out.packet.manifest.inline;
|
|
138
|
+
assert.equal(inline.engagement_report_summary, undefined);
|
|
139
|
+
assert.equal(inline.engagement_report_summary_degraded, true);
|
|
140
|
+
assert.equal(typeof inline.promise_ledger_report_summary, "object");
|
|
141
|
+
assert.equal(inline.promise_ledger_report_summary_degraded, undefined);
|
|
142
|
+
});
|
|
143
|
+
test("buildInstructionPacket does not inject narrative health when logs are missing (no summary, no degraded)", async () => {
|
|
144
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-narrative-health-no-logs-"));
|
|
145
|
+
await writeText(join(rootDir, "staging/chapters/chapter-001.md"), `# 第1章\n\n(占位)\n`);
|
|
146
|
+
const checkpoint = { last_completed_chapter: 0, current_volume: 1 };
|
|
147
|
+
const out = (await buildInstructionPacket({
|
|
148
|
+
rootDir,
|
|
149
|
+
checkpoint,
|
|
150
|
+
step: { kind: "chapter", chapter: 1, stage: "draft" },
|
|
151
|
+
embedMode: null,
|
|
152
|
+
writeManifest: false
|
|
153
|
+
}));
|
|
154
|
+
const inline = out.packet.manifest.inline;
|
|
155
|
+
assert.equal(inline.engagement_report_summary, undefined);
|
|
156
|
+
assert.equal(inline.engagement_report_summary_degraded, undefined);
|
|
157
|
+
assert.equal(inline.promise_ledger_report_summary, undefined);
|
|
158
|
+
assert.equal(inline.promise_ledger_report_summary_degraded, undefined);
|
|
159
|
+
const paths = out.packet.manifest.paths;
|
|
160
|
+
assert.equal(paths.engagement_report_latest, undefined);
|
|
161
|
+
assert.equal(paths.promise_ledger_report_latest, undefined);
|
|
162
|
+
});
|
|
163
|
+
test("buildInstructionPacket does not inject narrative health summaries for stage=summarize/judge", async () => {
|
|
164
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-narrative-health-no-inject-stages-"));
|
|
165
|
+
await writeJson(join(rootDir, "logs/engagement/latest.json"), {
|
|
166
|
+
schema_version: 1,
|
|
167
|
+
generated_at: "2026-03-03T00:00:00.000Z",
|
|
168
|
+
as_of: { chapter: 10, volume: 1 },
|
|
169
|
+
scope: { volume: 1, chapter_start: 1, chapter_end: 10 },
|
|
170
|
+
metrics_stream_path: "engagement-metrics.jsonl",
|
|
171
|
+
metrics: [],
|
|
172
|
+
stats: {
|
|
173
|
+
chapters: 10,
|
|
174
|
+
avg_word_count: 3000,
|
|
175
|
+
avg_plot_progression_beats: 2,
|
|
176
|
+
avg_conflict_intensity: 3,
|
|
177
|
+
avg_payoff_score: 2,
|
|
178
|
+
avg_new_info_load_score: 3
|
|
179
|
+
},
|
|
180
|
+
issues: [],
|
|
181
|
+
has_blocking_issues: false
|
|
182
|
+
});
|
|
183
|
+
await writeJson(join(rootDir, "logs/promises/latest.json"), {
|
|
184
|
+
schema_version: 1,
|
|
185
|
+
generated_at: "2026-03-03T00:00:00.000Z",
|
|
186
|
+
as_of: { chapter: 10, volume: 1 },
|
|
187
|
+
scope: { volume: 1, chapter_start: 1, chapter_end: 10 },
|
|
188
|
+
ledger_path: "promise-ledger.json",
|
|
189
|
+
policy: { dormancy_threshold_chapters: 12 },
|
|
190
|
+
stats: { total_promises: 0, promised_total: 0, advanced_total: 0, delivered_total: 0, open_total: 0, dormant_total: 0 },
|
|
191
|
+
dormant_promises: [],
|
|
192
|
+
issues: [],
|
|
193
|
+
has_blocking_issues: false
|
|
194
|
+
});
|
|
195
|
+
await writeText(join(rootDir, "staging/chapters/chapter-001.md"), `# 第1章\n\n(占位)\n`);
|
|
196
|
+
const checkpoint = { last_completed_chapter: 10, current_volume: 1 };
|
|
197
|
+
const summarizeOut = (await buildInstructionPacket({
|
|
198
|
+
rootDir,
|
|
199
|
+
checkpoint,
|
|
200
|
+
step: { kind: "chapter", chapter: 1, stage: "summarize" },
|
|
201
|
+
embedMode: null,
|
|
202
|
+
writeManifest: false
|
|
203
|
+
}));
|
|
204
|
+
const summarizeInline = summarizeOut.packet.manifest.inline;
|
|
205
|
+
assert.equal(summarizeInline.engagement_report_summary, undefined);
|
|
206
|
+
assert.equal(summarizeInline.engagement_report_summary_degraded, undefined);
|
|
207
|
+
assert.equal(summarizeInline.promise_ledger_report_summary, undefined);
|
|
208
|
+
assert.equal(summarizeInline.promise_ledger_report_summary_degraded, undefined);
|
|
209
|
+
const judgeOut = (await buildInstructionPacket({
|
|
210
|
+
rootDir,
|
|
211
|
+
checkpoint,
|
|
212
|
+
step: { kind: "chapter", chapter: 1, stage: "judge" },
|
|
213
|
+
embedMode: null,
|
|
214
|
+
writeManifest: false
|
|
215
|
+
}));
|
|
216
|
+
const judgeInline = judgeOut.packet.manifest.inline;
|
|
217
|
+
assert.equal(judgeInline.engagement_report_summary, undefined);
|
|
218
|
+
assert.equal(judgeInline.engagement_report_summary_degraded, undefined);
|
|
219
|
+
assert.equal(judgeInline.promise_ledger_report_summary, undefined);
|
|
220
|
+
assert.equal(judgeInline.promise_ledger_report_summary_degraded, undefined);
|
|
221
|
+
});
|
|
222
|
+
test("buildInstructionPacket marks degraded on schema_version mismatch when latest files exist", async () => {
|
|
223
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-narrative-health-schema-mismatch-"));
|
|
224
|
+
await writeJson(join(rootDir, "logs/engagement/latest.json"), {
|
|
225
|
+
schema_version: 2,
|
|
226
|
+
generated_at: "2026-03-03T00:00:00.000Z",
|
|
227
|
+
issues: []
|
|
228
|
+
});
|
|
229
|
+
await writeJson(join(rootDir, "logs/promises/latest.json"), {
|
|
230
|
+
schema_version: 1,
|
|
231
|
+
generated_at: "2026-03-03T00:00:00.000Z",
|
|
232
|
+
as_of: { chapter: 10, volume: 1 },
|
|
233
|
+
scope: { volume: 1, chapter_start: 1, chapter_end: 10 },
|
|
234
|
+
ledger_path: "promise-ledger.json",
|
|
235
|
+
policy: { dormancy_threshold_chapters: 12 },
|
|
236
|
+
stats: { total_promises: 0, promised_total: 0, advanced_total: 0, delivered_total: 0, open_total: 0, dormant_total: 0 },
|
|
237
|
+
dormant_promises: [],
|
|
238
|
+
issues: [],
|
|
239
|
+
has_blocking_issues: false
|
|
240
|
+
});
|
|
241
|
+
await writeText(join(rootDir, "staging/chapters/chapter-001.md"), `# 第1章\n\n(占位)\n`);
|
|
242
|
+
const checkpoint = { last_completed_chapter: 10, current_volume: 1 };
|
|
243
|
+
const out = (await buildInstructionPacket({
|
|
244
|
+
rootDir,
|
|
245
|
+
checkpoint,
|
|
246
|
+
step: { kind: "chapter", chapter: 1, stage: "draft" },
|
|
247
|
+
embedMode: null,
|
|
248
|
+
writeManifest: false
|
|
249
|
+
}));
|
|
250
|
+
const inline = out.packet.manifest.inline;
|
|
251
|
+
assert.equal(inline.engagement_report_summary, undefined);
|
|
252
|
+
assert.equal(inline.engagement_report_summary_degraded, true);
|
|
253
|
+
assert.equal(typeof inline.promise_ledger_report_summary, "object");
|
|
254
|
+
assert.equal(inline.promise_ledger_report_summary_degraded, undefined);
|
|
255
|
+
});
|
|
256
|
+
test("buildInstructionPacket marks promise ledger degraded on schema_version mismatch when latest file exists", async () => {
|
|
257
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-narrative-health-promise-schema-mismatch-"));
|
|
258
|
+
await writeJson(join(rootDir, "logs/engagement/latest.json"), {
|
|
259
|
+
schema_version: 1,
|
|
260
|
+
generated_at: "2026-03-03T00:00:00.000Z",
|
|
261
|
+
as_of: { chapter: 10, volume: 1 },
|
|
262
|
+
scope: { volume: 1, chapter_start: 1, chapter_end: 10 },
|
|
263
|
+
metrics_stream_path: "engagement-metrics.jsonl",
|
|
264
|
+
metrics: [],
|
|
265
|
+
stats: {
|
|
266
|
+
chapters: 10,
|
|
267
|
+
avg_word_count: 3000,
|
|
268
|
+
avg_plot_progression_beats: 2,
|
|
269
|
+
avg_conflict_intensity: 3,
|
|
270
|
+
avg_payoff_score: 2,
|
|
271
|
+
avg_new_info_load_score: 3
|
|
272
|
+
},
|
|
273
|
+
issues: [],
|
|
274
|
+
has_blocking_issues: false
|
|
275
|
+
});
|
|
276
|
+
await writeJson(join(rootDir, "logs/promises/latest.json"), {
|
|
277
|
+
schema_version: 2,
|
|
278
|
+
generated_at: "2026-03-03T00:00:00.000Z",
|
|
279
|
+
issues: []
|
|
280
|
+
});
|
|
281
|
+
await writeText(join(rootDir, "staging/chapters/chapter-001.md"), `# 第1章\n\n(占位)\n`);
|
|
282
|
+
const checkpoint = { last_completed_chapter: 10, current_volume: 1 };
|
|
283
|
+
const out = (await buildInstructionPacket({
|
|
284
|
+
rootDir,
|
|
285
|
+
checkpoint,
|
|
286
|
+
step: { kind: "chapter", chapter: 1, stage: "draft" },
|
|
287
|
+
embedMode: null,
|
|
288
|
+
writeManifest: false
|
|
289
|
+
}));
|
|
290
|
+
const inline = out.packet.manifest.inline;
|
|
291
|
+
assert.equal(typeof inline.engagement_report_summary, "object");
|
|
292
|
+
assert.equal(inline.engagement_report_summary_degraded, undefined);
|
|
293
|
+
assert.equal(inline.promise_ledger_report_summary, undefined);
|
|
294
|
+
assert.equal(inline.promise_ledger_report_summary_degraded, true);
|
|
295
|
+
});
|
|
296
|
+
test("buildInstructionPacket marks both degraded when both latest files are invalid", async () => {
|
|
297
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-narrative-health-both-degraded-"));
|
|
298
|
+
await writeText(join(rootDir, "logs/engagement/latest.json"), "not-json");
|
|
299
|
+
await writeText(join(rootDir, "logs/promises/latest.json"), "not-json");
|
|
300
|
+
await writeText(join(rootDir, "staging/chapters/chapter-001.md"), "# 第1章\n\n(占位)\n");
|
|
301
|
+
const checkpoint = { last_completed_chapter: 10, current_volume: 1 };
|
|
302
|
+
const out = (await buildInstructionPacket({
|
|
303
|
+
rootDir,
|
|
304
|
+
checkpoint,
|
|
305
|
+
step: { kind: "chapter", chapter: 1, stage: "draft" },
|
|
306
|
+
embedMode: null,
|
|
307
|
+
writeManifest: false
|
|
308
|
+
}));
|
|
309
|
+
const inline = out.packet?.manifest?.inline;
|
|
310
|
+
assert.equal(inline.engagement_report_summary, undefined);
|
|
311
|
+
assert.equal(inline.engagement_report_summary_degraded, true);
|
|
312
|
+
assert.equal(inline.promise_ledger_report_summary, undefined);
|
|
313
|
+
assert.equal(inline.promise_ledger_report_summary_degraded, true);
|
|
314
|
+
});
|
|
315
|
+
test("buildInstructionPacket treats oversized latest.json as degraded", async () => {
|
|
316
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-narrative-health-oversized-"));
|
|
317
|
+
// Write a latest.json that exceeds 512KB
|
|
318
|
+
const oversizedContent = JSON.stringify({
|
|
319
|
+
schema_version: 1,
|
|
320
|
+
generated_at: "2026-03-03T00:00:00.000Z",
|
|
321
|
+
as_of: { chapter: 10, volume: 1 },
|
|
322
|
+
scope: { volume: 1, chapter_start: 1, chapter_end: 10 },
|
|
323
|
+
metrics_stream_path: "engagement-metrics.jsonl",
|
|
324
|
+
metrics: [],
|
|
325
|
+
stats: { chapters: 10, avg_word_count: 3000, avg_plot_progression_beats: 2, avg_conflict_intensity: 3, avg_payoff_score: 2, avg_new_info_load_score: 3 },
|
|
326
|
+
issues: [{ id: "pad", severity: "warn", summary: "x".repeat(600000), suggestion: "y" }],
|
|
327
|
+
has_blocking_issues: false
|
|
328
|
+
});
|
|
329
|
+
await writeText(join(rootDir, "logs/engagement/latest.json"), oversizedContent);
|
|
330
|
+
// Promise latest is valid and small
|
|
331
|
+
await writeJson(join(rootDir, "logs/promises/latest.json"), {
|
|
332
|
+
schema_version: 1,
|
|
333
|
+
generated_at: "2026-03-03T00:00:00.000Z",
|
|
334
|
+
as_of: { chapter: 10, volume: 1 },
|
|
335
|
+
scope: { volume: 1, chapter_start: 1, chapter_end: 10 },
|
|
336
|
+
ledger_path: "promise-ledger.json",
|
|
337
|
+
policy: { dormancy_threshold_chapters: 12 },
|
|
338
|
+
stats: { total_promises: 0, promised_total: 0, advanced_total: 0, delivered_total: 0, open_total: 0, dormant_total: 0 },
|
|
339
|
+
dormant_promises: [],
|
|
340
|
+
issues: [],
|
|
341
|
+
has_blocking_issues: false
|
|
342
|
+
});
|
|
343
|
+
await writeText(join(rootDir, "staging/chapters/chapter-001.md"), "# 第1章\n\n(占位)\n");
|
|
344
|
+
const checkpoint = { last_completed_chapter: 10, current_volume: 1 };
|
|
345
|
+
const out = (await buildInstructionPacket({
|
|
346
|
+
rootDir,
|
|
347
|
+
checkpoint,
|
|
348
|
+
step: { kind: "chapter", chapter: 1, stage: "draft" },
|
|
349
|
+
embedMode: null,
|
|
350
|
+
writeManifest: false
|
|
351
|
+
}));
|
|
352
|
+
const inline = out.packet?.manifest?.inline;
|
|
353
|
+
// Engagement should be degraded due to oversized file
|
|
354
|
+
assert.equal(inline.engagement_report_summary, undefined);
|
|
355
|
+
assert.equal(inline.engagement_report_summary_degraded, true);
|
|
356
|
+
// Promise should be fine
|
|
357
|
+
assert.ok(inline.promise_ledger_report_summary);
|
|
358
|
+
assert.equal(inline.promise_ledger_report_summary_degraded, undefined);
|
|
359
|
+
});
|