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.
Files changed (116) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +103 -0
  3. package/agents/chapter-writer.md +142 -0
  4. package/agents/character-weaver.md +117 -0
  5. package/agents/consistency-auditor.md +85 -0
  6. package/agents/plot-architect.md +128 -0
  7. package/agents/quality-judge.md +232 -0
  8. package/agents/style-analyzer.md +109 -0
  9. package/agents/style-refiner.md +97 -0
  10. package/agents/summarizer.md +128 -0
  11. package/agents/world-builder.md +161 -0
  12. package/dist/__tests__/character-voice.test.js +445 -0
  13. package/dist/__tests__/commit-prototype-pollution.test.js +45 -0
  14. package/dist/__tests__/engagement.test.js +382 -0
  15. package/dist/__tests__/foreshadow-visibility.test.js +131 -0
  16. package/dist/__tests__/hook-ledger.test.js +1028 -0
  17. package/dist/__tests__/naming-lint.test.js +132 -0
  18. package/dist/__tests__/narrative-health-injection.test.js +359 -0
  19. package/dist/__tests__/next-step-prejudge-guardrails.test.js +325 -0
  20. package/dist/__tests__/next-step-title-fix.test.js +153 -0
  21. package/dist/__tests__/platform-profile.test.js +274 -0
  22. package/dist/__tests__/promise-ledger.test.js +189 -0
  23. package/dist/__tests__/readability-lint.test.js +209 -0
  24. package/dist/__tests__/text-utils.test.js +39 -0
  25. package/dist/__tests__/title-policy.test.js +147 -0
  26. package/dist/advance.js +75 -0
  27. package/dist/character-voice.js +805 -0
  28. package/dist/checkpoint.js +126 -0
  29. package/dist/cli.js +563 -0
  30. package/dist/cliche-lint.js +515 -0
  31. package/dist/commit.js +1460 -0
  32. package/dist/consistency-auditor.js +684 -0
  33. package/dist/engagement.js +687 -0
  34. package/dist/errors.js +7 -0
  35. package/dist/fingerprint.js +16 -0
  36. package/dist/foreshadow-visibility.js +214 -0
  37. package/dist/fs-utils.js +68 -0
  38. package/dist/hook-ledger.js +721 -0
  39. package/dist/hook-policy.js +107 -0
  40. package/dist/instruction-gates.js +51 -0
  41. package/dist/instructions.js +406 -0
  42. package/dist/latest-summary-loader.js +29 -0
  43. package/dist/lock.js +121 -0
  44. package/dist/naming-lint.js +531 -0
  45. package/dist/ner.js +73 -0
  46. package/dist/next-step.js +408 -0
  47. package/dist/novel-ask.js +270 -0
  48. package/dist/output.js +9 -0
  49. package/dist/platform-constraints.js +518 -0
  50. package/dist/platform-profile.js +325 -0
  51. package/dist/prejudge-guardrails.js +370 -0
  52. package/dist/project.js +40 -0
  53. package/dist/promise-ledger.js +723 -0
  54. package/dist/readability-lint.js +555 -0
  55. package/dist/safe-parse.js +36 -0
  56. package/dist/safe-path.js +29 -0
  57. package/dist/scoring-weights.js +290 -0
  58. package/dist/steps.js +60 -0
  59. package/dist/text-utils.js +18 -0
  60. package/dist/title-policy.js +251 -0
  61. package/dist/type-guards.js +6 -0
  62. package/dist/validate.js +131 -0
  63. package/docs/user/README.md +17 -0
  64. package/docs/user/guardrails.md +179 -0
  65. package/docs/user/interactive-gates.md +124 -0
  66. package/docs/user/novel-cli.md +289 -0
  67. package/docs/user/ops.md +123 -0
  68. package/docs/user/quick-start.md +97 -0
  69. package/docs/user/spec-system.md +166 -0
  70. package/docs/user/storylines.md +144 -0
  71. package/package.json +48 -0
  72. package/schemas/README.md +18 -0
  73. package/schemas/character-voice-drift.schema.json +135 -0
  74. package/schemas/character-voice-profiles.schema.json +141 -0
  75. package/schemas/engagement-metrics.schema.json +38 -0
  76. package/schemas/hook-ledger.schema.json +108 -0
  77. package/schemas/platform-profile.schema.json +235 -0
  78. package/schemas/promise-ledger.schema.json +97 -0
  79. package/scripts/calibrate-quality-judge.sh +91 -0
  80. package/scripts/compare-regression-runs.sh +86 -0
  81. package/scripts/lib/_common.py +131 -0
  82. package/scripts/lib/calibrate_quality_judge.py +312 -0
  83. package/scripts/lib/compare_regression_runs.py +142 -0
  84. package/scripts/lib/run_regression.py +621 -0
  85. package/scripts/lint-blacklist.sh +201 -0
  86. package/scripts/lint-cliche.sh +370 -0
  87. package/scripts/lint-readability.sh +404 -0
  88. package/scripts/query-foreshadow.sh +252 -0
  89. package/scripts/run-ner.sh +669 -0
  90. package/scripts/run-regression.sh +122 -0
  91. package/skills/cli-step/SKILL.md +158 -0
  92. package/skills/continue/SKILL.md +348 -0
  93. package/skills/continue/references/context-contracts.md +169 -0
  94. package/skills/continue/references/continuity-checks.md +187 -0
  95. package/skills/continue/references/file-protocols.md +64 -0
  96. package/skills/continue/references/foreshadowing.md +130 -0
  97. package/skills/continue/references/gate-decision.md +53 -0
  98. package/skills/continue/references/periodic-maintenance.md +46 -0
  99. package/skills/novel-writing/SKILL.md +77 -0
  100. package/skills/novel-writing/references/quality-rubric.md +140 -0
  101. package/skills/novel-writing/references/style-guide.md +145 -0
  102. package/skills/start/SKILL.md +458 -0
  103. package/skills/start/references/quality-review.md +86 -0
  104. package/skills/start/references/setting-update.md +44 -0
  105. package/skills/start/references/vol-planning.md +61 -0
  106. package/skills/start/references/vol-review.md +58 -0
  107. package/skills/status/SKILL.md +116 -0
  108. package/skills/status/references/sample-output.md +60 -0
  109. package/templates/ai-blacklist.json +79 -0
  110. package/templates/brief-template.md +46 -0
  111. package/templates/genre-weight-profiles.json +90 -0
  112. package/templates/novel-ask/example.answer.json +12 -0
  113. package/templates/novel-ask/example.question.json +51 -0
  114. package/templates/platform-profile.json +148 -0
  115. package/templates/style-profile-template.json +58 -0
  116. 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
+ });