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,721 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { NovelCliError } from "./errors.js";
|
|
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 pickCommentFields(obj) {
|
|
7
|
+
const out = Object.create(null);
|
|
8
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
9
|
+
if (!k.startsWith("_"))
|
|
10
|
+
continue;
|
|
11
|
+
if (k === "__proto__" || k === "constructor" || k === "prototype")
|
|
12
|
+
continue;
|
|
13
|
+
out[k] = v;
|
|
14
|
+
}
|
|
15
|
+
return out;
|
|
16
|
+
}
|
|
17
|
+
function safeInt(v) {
|
|
18
|
+
return typeof v === "number" && Number.isInteger(v) ? v : null;
|
|
19
|
+
}
|
|
20
|
+
function safePositiveInt(v) {
|
|
21
|
+
const n = safeInt(v);
|
|
22
|
+
return n !== null && n > 0 ? n : null;
|
|
23
|
+
}
|
|
24
|
+
function safeString(v) {
|
|
25
|
+
if (typeof v !== "string")
|
|
26
|
+
return null;
|
|
27
|
+
const t = v.trim();
|
|
28
|
+
return t.length > 0 ? t : null;
|
|
29
|
+
}
|
|
30
|
+
function safeHookStatus(v) {
|
|
31
|
+
if (v === "open" || v === "fulfilled" || v === "lapsed")
|
|
32
|
+
return v;
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
const RFC3339_DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/u;
|
|
36
|
+
function safeIso(v) {
|
|
37
|
+
if (typeof v !== "string")
|
|
38
|
+
return null;
|
|
39
|
+
const s = v.trim();
|
|
40
|
+
if (!RFC3339_DATE_TIME.test(s))
|
|
41
|
+
return null;
|
|
42
|
+
return Number.isFinite(Date.parse(s)) ? s : null;
|
|
43
|
+
}
|
|
44
|
+
function safeWindow(v) {
|
|
45
|
+
if (!Array.isArray(v) || v.length !== 2)
|
|
46
|
+
return null;
|
|
47
|
+
const a = safePositiveInt(v[0]);
|
|
48
|
+
const b = safePositiveInt(v[1]);
|
|
49
|
+
if (a === null || b === null)
|
|
50
|
+
return null;
|
|
51
|
+
if (a > b)
|
|
52
|
+
return null;
|
|
53
|
+
return [a, b];
|
|
54
|
+
}
|
|
55
|
+
function safeWindowAfterChapter(v, chapter) {
|
|
56
|
+
const w = safeWindow(v);
|
|
57
|
+
if (!w)
|
|
58
|
+
return null;
|
|
59
|
+
return w[0] > chapter ? w : null;
|
|
60
|
+
}
|
|
61
|
+
function normalizeLinks(raw) {
|
|
62
|
+
if (!isPlainObject(raw))
|
|
63
|
+
return null;
|
|
64
|
+
const obj = raw;
|
|
65
|
+
const promise_ids = Array.isArray(obj.promise_ids)
|
|
66
|
+
? Array.from(new Set(obj.promise_ids.filter((v) => typeof v === "string").map((v) => v.trim()).filter((v) => v.length > 0)))
|
|
67
|
+
: null;
|
|
68
|
+
const foreshadowing_ids = Array.isArray(obj.foreshadowing_ids)
|
|
69
|
+
? Array.from(new Set(obj.foreshadowing_ids.filter((v) => typeof v === "string").map((v) => v.trim()).filter((v) => v.length > 0)))
|
|
70
|
+
: null;
|
|
71
|
+
const out = {};
|
|
72
|
+
if (promise_ids && promise_ids.length > 0)
|
|
73
|
+
out.promise_ids = promise_ids;
|
|
74
|
+
if (foreshadowing_ids && foreshadowing_ids.length > 0)
|
|
75
|
+
out.foreshadowing_ids = foreshadowing_ids;
|
|
76
|
+
return Object.keys(out).length > 0 ? out : null;
|
|
77
|
+
}
|
|
78
|
+
function hookPromiseText(hookType) {
|
|
79
|
+
switch (hookType) {
|
|
80
|
+
case "question":
|
|
81
|
+
return "留悬念:未解之问";
|
|
82
|
+
case "threat_reveal":
|
|
83
|
+
return "留悬念:威胁升级";
|
|
84
|
+
case "twist_reveal":
|
|
85
|
+
return "留悬念:反转揭示";
|
|
86
|
+
case "emotional_cliff":
|
|
87
|
+
return "留悬念:情绪悬崖";
|
|
88
|
+
case "next_objective":
|
|
89
|
+
return "留悬念:新目标";
|
|
90
|
+
default:
|
|
91
|
+
return `留悬念:${hookType}`;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function snippet(text, maxLen) {
|
|
95
|
+
const s = text.trim().replace(/\s+/gu, " ");
|
|
96
|
+
if (s.length <= maxLen)
|
|
97
|
+
return s;
|
|
98
|
+
let end = Math.max(0, maxLen - 1);
|
|
99
|
+
if (end > 0) {
|
|
100
|
+
const last = s.charCodeAt(end - 1);
|
|
101
|
+
if (last >= 0xd800 && last <= 0xdbff) {
|
|
102
|
+
const next = s.charCodeAt(end);
|
|
103
|
+
if (next >= 0xdc00 && next <= 0xdfff)
|
|
104
|
+
end -= 1;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return `${s.slice(0, end)}…`;
|
|
108
|
+
}
|
|
109
|
+
function extractHookSignals(evalRaw) {
|
|
110
|
+
if (!isPlainObject(evalRaw))
|
|
111
|
+
return { present: null, type: null, evidence: null, strength: null };
|
|
112
|
+
const root = evalRaw;
|
|
113
|
+
const evalObj = isPlainObject(root.eval_used) ? root.eval_used : root;
|
|
114
|
+
// Hook fields.
|
|
115
|
+
let present = null;
|
|
116
|
+
let type = null;
|
|
117
|
+
let evidence = null;
|
|
118
|
+
const hookRaw = evalObj.hook;
|
|
119
|
+
if (isPlainObject(hookRaw)) {
|
|
120
|
+
const hookObj = hookRaw;
|
|
121
|
+
present = typeof hookObj.present === "boolean" ? hookObj.present : null;
|
|
122
|
+
type = safeString(hookObj.type);
|
|
123
|
+
evidence = safeString(hookObj.evidence);
|
|
124
|
+
}
|
|
125
|
+
// Strength fields.
|
|
126
|
+
let strength = null;
|
|
127
|
+
const scoresRaw = evalObj.scores;
|
|
128
|
+
if (isPlainObject(scoresRaw)) {
|
|
129
|
+
const hsRaw = scoresRaw.hook_strength;
|
|
130
|
+
if (isPlainObject(hsRaw)) {
|
|
131
|
+
strength = safeInt(hsRaw.score);
|
|
132
|
+
if (evidence === null)
|
|
133
|
+
evidence = safeString(hsRaw.evidence);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (strength === null) {
|
|
137
|
+
const legacy = safeInt(evalObj.hook_strength);
|
|
138
|
+
if (legacy !== null)
|
|
139
|
+
strength = legacy;
|
|
140
|
+
}
|
|
141
|
+
if (strength === null && isPlainObject(hookRaw)) {
|
|
142
|
+
const hookObj = hookRaw;
|
|
143
|
+
const legacyStrength = safeInt(hookObj.strength);
|
|
144
|
+
if (legacyStrength !== null)
|
|
145
|
+
strength = legacyStrength;
|
|
146
|
+
if (evidence === null)
|
|
147
|
+
evidence = safeString(hookObj.evidence);
|
|
148
|
+
}
|
|
149
|
+
const hookType = type ? type.toLowerCase() : null;
|
|
150
|
+
return { present, type: hookType, evidence, strength };
|
|
151
|
+
}
|
|
152
|
+
function normalizeExistingEntry(raw, now, warnings) {
|
|
153
|
+
if (!isPlainObject(raw)) {
|
|
154
|
+
warnings.push("Dropped non-object hook ledger entry.");
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
const obj = raw;
|
|
158
|
+
const comments = pickCommentFields(obj);
|
|
159
|
+
const id = safeString(obj.id);
|
|
160
|
+
const chapter = safePositiveInt(obj.chapter);
|
|
161
|
+
if (!id || chapter === null) {
|
|
162
|
+
warnings.push("Dropped hook ledger entry missing id/chapter.");
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
const hook_type = safeString(obj.hook_type)?.toLowerCase() ?? "unknown";
|
|
166
|
+
const rawHookStrength = obj.hook_strength;
|
|
167
|
+
let hook_strength = safeInt(rawHookStrength);
|
|
168
|
+
if (hook_strength === null || hook_strength < 1 || hook_strength > 5) {
|
|
169
|
+
if (rawHookStrength !== undefined && comments._invalid_hook_strength === undefined) {
|
|
170
|
+
comments._invalid_hook_strength = rawHookStrength;
|
|
171
|
+
}
|
|
172
|
+
hook_strength = 3;
|
|
173
|
+
warnings.push(`Hook ledger entry '${id}' has invalid hook_strength; defaulted to 3.`);
|
|
174
|
+
}
|
|
175
|
+
const promise_text = safeString(obj.promise_text) ?? hookPromiseText(hook_type);
|
|
176
|
+
let status = safeHookStatus(obj.status) ?? "open";
|
|
177
|
+
const rawWindow = obj.fulfillment_window;
|
|
178
|
+
const window = safeWindowAfterChapter(rawWindow, chapter);
|
|
179
|
+
const fulfillment_window = window ?? [chapter + 1, chapter + 1];
|
|
180
|
+
if (!window && comments._needs_window_backfill === undefined) {
|
|
181
|
+
comments._needs_window_backfill = true;
|
|
182
|
+
}
|
|
183
|
+
if (!window && rawWindow !== undefined && comments._invalid_fulfillment_window === undefined) {
|
|
184
|
+
comments._invalid_fulfillment_window = rawWindow;
|
|
185
|
+
warnings.push(`Hook ledger entry '${id}' has invalid fulfillment_window; will backfill.`);
|
|
186
|
+
}
|
|
187
|
+
const fulfilled_chapter = safePositiveInt(obj.fulfilled_chapter) ?? null;
|
|
188
|
+
const didAutoFixStatus = fulfilled_chapter !== null && status !== "fulfilled";
|
|
189
|
+
if (didAutoFixStatus) {
|
|
190
|
+
warnings.push(`Hook ledger entry '${id}' has fulfilled_chapter set but status='${status}'; auto-corrected to status='fulfilled'.`);
|
|
191
|
+
status = "fulfilled";
|
|
192
|
+
}
|
|
193
|
+
const rawCreatedAt = obj.created_at;
|
|
194
|
+
let created_at = safeIso(rawCreatedAt);
|
|
195
|
+
if (!created_at) {
|
|
196
|
+
if (rawCreatedAt === undefined) {
|
|
197
|
+
if (comments._missing_created_at === undefined)
|
|
198
|
+
comments._missing_created_at = true;
|
|
199
|
+
warnings.push(`Hook ledger entry '${id}' is missing created_at; defaulted to now.`);
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
if (comments._invalid_created_at === undefined)
|
|
203
|
+
comments._invalid_created_at = rawCreatedAt;
|
|
204
|
+
warnings.push(`Hook ledger entry '${id}' has invalid created_at; defaulted to now.`);
|
|
205
|
+
}
|
|
206
|
+
created_at = now;
|
|
207
|
+
}
|
|
208
|
+
const rawUpdatedAt = obj.updated_at;
|
|
209
|
+
let updated_at = safeIso(rawUpdatedAt);
|
|
210
|
+
if (!updated_at) {
|
|
211
|
+
if (rawUpdatedAt === undefined) {
|
|
212
|
+
if (comments._missing_updated_at === undefined)
|
|
213
|
+
comments._missing_updated_at = true;
|
|
214
|
+
warnings.push(`Hook ledger entry '${id}' is missing updated_at; defaulted to created_at.`);
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
if (comments._invalid_updated_at === undefined)
|
|
218
|
+
comments._invalid_updated_at = rawUpdatedAt;
|
|
219
|
+
warnings.push(`Hook ledger entry '${id}' has invalid updated_at; defaulted to created_at.`);
|
|
220
|
+
}
|
|
221
|
+
updated_at = created_at;
|
|
222
|
+
}
|
|
223
|
+
const createdTs = Date.parse(created_at);
|
|
224
|
+
const updatedTs = Date.parse(updated_at);
|
|
225
|
+
if (Number.isFinite(createdTs) && Number.isFinite(updatedTs) && createdTs > updatedTs) {
|
|
226
|
+
if (comments._created_at_clamped_to_updated_at === undefined)
|
|
227
|
+
comments._created_at_clamped_to_updated_at = true;
|
|
228
|
+
warnings.push(`Hook ledger entry '${id}' has created_at after updated_at; clamped created_at to updated_at.`);
|
|
229
|
+
created_at = updated_at;
|
|
230
|
+
}
|
|
231
|
+
const evidence_snippet = safeString(obj.evidence_snippet) ?? undefined;
|
|
232
|
+
const sources = isPlainObject(obj.sources) ? obj.sources : null;
|
|
233
|
+
const eval_path = sources ? safeString(sources.eval_path) : null;
|
|
234
|
+
const links = normalizeLinks(obj.links);
|
|
235
|
+
const historyRaw = Array.isArray(obj.history) ? obj.history : null;
|
|
236
|
+
const history = [];
|
|
237
|
+
if (historyRaw) {
|
|
238
|
+
for (const h of historyRaw) {
|
|
239
|
+
if (!isPlainObject(h))
|
|
240
|
+
continue;
|
|
241
|
+
const ho = h;
|
|
242
|
+
const at = safeIso(ho.at) ?? null;
|
|
243
|
+
const hChapter = safePositiveInt(ho.chapter);
|
|
244
|
+
const action = safeString(ho.action);
|
|
245
|
+
if (!at || hChapter === null || !action)
|
|
246
|
+
continue;
|
|
247
|
+
const detail = safeString(ho.detail) ?? undefined;
|
|
248
|
+
history.push({ at, chapter: hChapter, action, ...(detail ? { detail } : {}) });
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (didAutoFixStatus) {
|
|
252
|
+
history.push({ at: now, chapter, action: "status_auto_fixed", detail: "fulfilled_chapter set" });
|
|
253
|
+
}
|
|
254
|
+
const entry = {
|
|
255
|
+
...comments,
|
|
256
|
+
id,
|
|
257
|
+
chapter,
|
|
258
|
+
hook_type,
|
|
259
|
+
hook_strength,
|
|
260
|
+
promise_text,
|
|
261
|
+
status,
|
|
262
|
+
fulfillment_window,
|
|
263
|
+
fulfilled_chapter,
|
|
264
|
+
created_at,
|
|
265
|
+
updated_at,
|
|
266
|
+
...(evidence_snippet ? { evidence_snippet } : {}),
|
|
267
|
+
...(eval_path ? { sources: { eval_path } } : {}),
|
|
268
|
+
...(links ? { links } : {}),
|
|
269
|
+
...(history.length > 0 ? { history } : {})
|
|
270
|
+
};
|
|
271
|
+
return entry;
|
|
272
|
+
}
|
|
273
|
+
export async function loadHookLedger(rootDir) {
|
|
274
|
+
const rel = "hook-ledger.json";
|
|
275
|
+
const abs = join(rootDir, rel);
|
|
276
|
+
if (!(await pathExists(abs))) {
|
|
277
|
+
return {
|
|
278
|
+
ledger: {
|
|
279
|
+
$schema: "schemas/hook-ledger.schema.json",
|
|
280
|
+
schema_version: 1,
|
|
281
|
+
entries: []
|
|
282
|
+
},
|
|
283
|
+
warnings: []
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
const raw = await readJsonFile(abs);
|
|
287
|
+
if (!isPlainObject(raw))
|
|
288
|
+
throw new NovelCliError(`Invalid ${rel}: expected a JSON object.`, 2);
|
|
289
|
+
const obj = raw;
|
|
290
|
+
const comments = pickCommentFields(obj);
|
|
291
|
+
if (obj.schema_version === undefined) {
|
|
292
|
+
throw new NovelCliError(`Invalid ${rel}: missing required 'schema_version'.`, 2);
|
|
293
|
+
}
|
|
294
|
+
const schemaVersion = obj.schema_version;
|
|
295
|
+
if (schemaVersion !== 1) {
|
|
296
|
+
throw new NovelCliError(`Invalid ${rel}: 'schema_version' must be 1.`, 2);
|
|
297
|
+
}
|
|
298
|
+
const now = new Date().toISOString();
|
|
299
|
+
const warnings = [];
|
|
300
|
+
if (obj.entries === undefined) {
|
|
301
|
+
throw new NovelCliError(`Invalid ${rel}: missing required 'entries' array.`, 2);
|
|
302
|
+
}
|
|
303
|
+
if (!Array.isArray(obj.entries)) {
|
|
304
|
+
throw new NovelCliError(`Invalid ${rel}: 'entries' must be an array.`, 2);
|
|
305
|
+
}
|
|
306
|
+
const entriesRaw = obj.entries;
|
|
307
|
+
const entries = [];
|
|
308
|
+
for (const it of entriesRaw) {
|
|
309
|
+
const entry = normalizeExistingEntry(it, now, warnings);
|
|
310
|
+
if (entry)
|
|
311
|
+
entries.push(entry);
|
|
312
|
+
}
|
|
313
|
+
return {
|
|
314
|
+
ledger: {
|
|
315
|
+
$schema: "schemas/hook-ledger.schema.json",
|
|
316
|
+
schema_version: 1,
|
|
317
|
+
entries,
|
|
318
|
+
...comments
|
|
319
|
+
},
|
|
320
|
+
warnings
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
function retentionHistoryRel(args) {
|
|
324
|
+
return `logs/retention/retention-report-vol-${pad2(args.volume)}-ch${pad3(args.start)}-ch${pad3(args.end)}.json`;
|
|
325
|
+
}
|
|
326
|
+
export async function writeHookLedgerFile(args) {
|
|
327
|
+
const rel = "hook-ledger.json";
|
|
328
|
+
await writeJsonFile(join(args.rootDir, rel), args.ledger);
|
|
329
|
+
return { rel };
|
|
330
|
+
}
|
|
331
|
+
export async function writeRetentionLogs(args) {
|
|
332
|
+
const dirRel = "logs/retention";
|
|
333
|
+
const dirAbs = join(args.rootDir, dirRel);
|
|
334
|
+
await ensureDir(dirAbs);
|
|
335
|
+
const latestRel = `${dirRel}/latest.json`;
|
|
336
|
+
await writeJsonFile(join(args.rootDir, latestRel), args.report);
|
|
337
|
+
const result = { latestRel };
|
|
338
|
+
if (args.writeHistory) {
|
|
339
|
+
const historyRel = retentionHistoryRel({
|
|
340
|
+
volume: args.report.scope.volume,
|
|
341
|
+
start: args.report.scope.chapter_start,
|
|
342
|
+
end: args.report.scope.chapter_end
|
|
343
|
+
});
|
|
344
|
+
await writeJsonFile(join(args.rootDir, historyRel), args.report);
|
|
345
|
+
result.historyRel = historyRel;
|
|
346
|
+
}
|
|
347
|
+
return result;
|
|
348
|
+
}
|
|
349
|
+
export async function attachHookLedgerToEval(args) {
|
|
350
|
+
const raw = await readJsonFile(args.evalAbsPath);
|
|
351
|
+
if (!isPlainObject(raw))
|
|
352
|
+
throw new NovelCliError(`Invalid ${args.evalRelPath}: eval JSON must be an object.`, 2);
|
|
353
|
+
const obj = raw;
|
|
354
|
+
const bySeverity = { warn: 0, soft: 0, hard: 0 };
|
|
355
|
+
for (const issue of args.report.issues) {
|
|
356
|
+
if (issue.severity === "warn")
|
|
357
|
+
bySeverity.warn += 1;
|
|
358
|
+
else if (issue.severity === "soft")
|
|
359
|
+
bySeverity.soft += 1;
|
|
360
|
+
else if (issue.severity === "hard")
|
|
361
|
+
bySeverity.hard += 1;
|
|
362
|
+
}
|
|
363
|
+
obj.hook_ledger = {
|
|
364
|
+
ledger_path: args.ledgerRelPath,
|
|
365
|
+
report_latest_path: args.reportLatestRelPath,
|
|
366
|
+
...(args.reportHistoryRelPath ? { report_history_path: args.reportHistoryRelPath } : {}),
|
|
367
|
+
entry: {
|
|
368
|
+
id: args.entry.id,
|
|
369
|
+
chapter: args.entry.chapter,
|
|
370
|
+
hook_type: args.entry.hook_type,
|
|
371
|
+
hook_strength: args.entry.hook_strength,
|
|
372
|
+
promise_text: args.entry.promise_text,
|
|
373
|
+
status: args.entry.status,
|
|
374
|
+
fulfillment_window: args.entry.fulfillment_window,
|
|
375
|
+
fulfilled_chapter: args.entry.fulfilled_chapter,
|
|
376
|
+
...(args.entry.evidence_snippet ? { evidence_snippet: args.entry.evidence_snippet } : {})
|
|
377
|
+
},
|
|
378
|
+
issues_total: args.report.issues.length,
|
|
379
|
+
issues_by_severity: bySeverity,
|
|
380
|
+
has_blocking_issues: args.report.has_blocking_issues
|
|
381
|
+
};
|
|
382
|
+
await writeJsonFile(args.evalAbsPath, obj);
|
|
383
|
+
}
|
|
384
|
+
function summarizeEntry(e) {
|
|
385
|
+
return {
|
|
386
|
+
id: e.id,
|
|
387
|
+
chapter: e.chapter,
|
|
388
|
+
hook_type: e.hook_type,
|
|
389
|
+
hook_strength: e.hook_strength,
|
|
390
|
+
promise_text: e.promise_text,
|
|
391
|
+
status: e.status,
|
|
392
|
+
fulfillment_window: e.fulfillment_window,
|
|
393
|
+
fulfilled_chapter: e.fulfilled_chapter,
|
|
394
|
+
...(e.evidence_snippet ? { evidence_snippet: e.evidence_snippet } : {})
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
function computeMaxSameTypeStreak(typesByChapter) {
|
|
398
|
+
let max = 0;
|
|
399
|
+
let maxType = null;
|
|
400
|
+
let currentType = null;
|
|
401
|
+
let current = 0;
|
|
402
|
+
for (const it of typesByChapter) {
|
|
403
|
+
const t = it.hook_type;
|
|
404
|
+
if (!t || t === "none" || t === "unknown") {
|
|
405
|
+
currentType = null;
|
|
406
|
+
current = 0;
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
if (currentType === t) {
|
|
410
|
+
current += 1;
|
|
411
|
+
}
|
|
412
|
+
else {
|
|
413
|
+
currentType = t;
|
|
414
|
+
current = 1;
|
|
415
|
+
}
|
|
416
|
+
if (current > max) {
|
|
417
|
+
max = current;
|
|
418
|
+
maxType = currentType;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return { max, type: maxType };
|
|
422
|
+
}
|
|
423
|
+
function parseIsoTimestamp(value) {
|
|
424
|
+
if (typeof value !== "string")
|
|
425
|
+
return null;
|
|
426
|
+
const t = Date.parse(value);
|
|
427
|
+
return Number.isFinite(t) ? t : null;
|
|
428
|
+
}
|
|
429
|
+
function entryTimestamp(e) {
|
|
430
|
+
return parseIsoTimestamp(e.updated_at) ?? parseIsoTimestamp(e.created_at) ?? null;
|
|
431
|
+
}
|
|
432
|
+
function statusRank(status) {
|
|
433
|
+
return status === "fulfilled" ? 3 : status === "lapsed" ? 2 : 1;
|
|
434
|
+
}
|
|
435
|
+
export function computeHookLedgerUpdate(args) {
|
|
436
|
+
const now = new Date().toISOString();
|
|
437
|
+
const warnings = [];
|
|
438
|
+
const ledgerComments = pickCommentFields(args.ledger);
|
|
439
|
+
const existingEntries = [];
|
|
440
|
+
for (const it of args.ledger.entries) {
|
|
441
|
+
const normalized = normalizeExistingEntry(it, now, warnings);
|
|
442
|
+
if (normalized)
|
|
443
|
+
existingEntries.push(normalized);
|
|
444
|
+
}
|
|
445
|
+
// Unique by chapter: preserve "fulfilled" as user-authored state; otherwise prefer newest timestamps.
|
|
446
|
+
const byChapter = new Map();
|
|
447
|
+
const dropped = [];
|
|
448
|
+
for (const e of existingEntries) {
|
|
449
|
+
const prev = byChapter.get(e.chapter);
|
|
450
|
+
if (!prev) {
|
|
451
|
+
byChapter.set(e.chapter, e);
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
const prevIsFulfilled = prev.status === "fulfilled";
|
|
455
|
+
const nextIsFulfilled = e.status === "fulfilled";
|
|
456
|
+
if (prevIsFulfilled && !nextIsFulfilled) {
|
|
457
|
+
dropped.push({ chapter: e.chapter, kept: prev, dropped: e });
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
if (!prevIsFulfilled && nextIsFulfilled) {
|
|
461
|
+
dropped.push({ chapter: e.chapter, kept: e, dropped: prev });
|
|
462
|
+
byChapter.set(e.chapter, e);
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
const prevTs = entryTimestamp(prev);
|
|
466
|
+
const nextTs = entryTimestamp(e);
|
|
467
|
+
if (prevTs !== null && nextTs !== null) {
|
|
468
|
+
if (nextTs > prevTs) {
|
|
469
|
+
dropped.push({ chapter: e.chapter, kept: e, dropped: prev });
|
|
470
|
+
byChapter.set(e.chapter, e);
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
if (nextTs < prevTs) {
|
|
474
|
+
dropped.push({ chapter: e.chapter, kept: prev, dropped: e });
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
else if (prevTs === null && nextTs !== null) {
|
|
479
|
+
dropped.push({ chapter: e.chapter, kept: e, dropped: prev });
|
|
480
|
+
byChapter.set(e.chapter, e);
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
else if (prevTs !== null && nextTs === null) {
|
|
484
|
+
dropped.push({ chapter: e.chapter, kept: prev, dropped: e });
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
const prevRank = statusRank(prev.status);
|
|
488
|
+
const nextRank = statusRank(e.status);
|
|
489
|
+
if (nextRank > prevRank) {
|
|
490
|
+
dropped.push({ chapter: e.chapter, kept: e, dropped: prev });
|
|
491
|
+
byChapter.set(e.chapter, e);
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
dropped.push({ chapter: e.chapter, kept: prev, dropped: e });
|
|
495
|
+
}
|
|
496
|
+
if (dropped.length > 0) {
|
|
497
|
+
const samples = dropped
|
|
498
|
+
.slice(0, 3)
|
|
499
|
+
.map((d) => `ch${pad3(d.chapter)} keep=${d.kept.id}(${d.kept.status}) drop=${d.dropped.id}(${d.dropped.status})`)
|
|
500
|
+
.join(" | ");
|
|
501
|
+
const suffix = dropped.length > 3 ? " …" : "";
|
|
502
|
+
const detail = samples.length > 0 ? ` ${samples}${suffix}` : "";
|
|
503
|
+
warnings.push(`Dropped ${dropped.length} duplicate hook ledger entries (duplicate chapter numbers).${detail}`);
|
|
504
|
+
}
|
|
505
|
+
const entries = Array.from(byChapter.values()).sort((a, b) => a.chapter - b.chapter || a.id.localeCompare(b.id, "en"));
|
|
506
|
+
const evalSignals = extractHookSignals(args.evalRaw);
|
|
507
|
+
const hookType = evalSignals.type;
|
|
508
|
+
const hookStrength = evalSignals.strength;
|
|
509
|
+
const hookEvidence = evalSignals.evidence;
|
|
510
|
+
const hookPresentExplicit = evalSignals.present;
|
|
511
|
+
const hookPresent = hookPresentExplicit === true || (hookPresentExplicit === null && hookType !== null && hookType !== "none" && hookType.trim().length > 0);
|
|
512
|
+
if (hookPresentExplicit === true && (!hookType || hookType === "none")) {
|
|
513
|
+
warnings.push("Eval hook.present=true but hook.type is missing/none; skipping hook-ledger upsert.");
|
|
514
|
+
}
|
|
515
|
+
else if (hookPresentExplicit === false && hookType && hookType !== "none") {
|
|
516
|
+
warnings.push(`Eval hook.present=false but hook.type='${hookType}'; treating as no hook.`);
|
|
517
|
+
}
|
|
518
|
+
else if (hookPresentExplicit === null && hookType && hookType !== "none") {
|
|
519
|
+
warnings.push("Eval hook.present is missing; inferred hook.present=true from hook.type.");
|
|
520
|
+
}
|
|
521
|
+
const existingAtChapter = entries.find((e) => e.chapter === args.chapter) ?? null;
|
|
522
|
+
let entry = null;
|
|
523
|
+
if (hookPresent && hookType && hookType !== "none") {
|
|
524
|
+
const existing = entries.find((e) => e.chapter === args.chapter) ?? null;
|
|
525
|
+
if (existing && existing.status === "fulfilled") {
|
|
526
|
+
// Fulfilled entries are treated as user-authored state; do not overwrite fields from a new eval.
|
|
527
|
+
entry = existing;
|
|
528
|
+
}
|
|
529
|
+
else {
|
|
530
|
+
const id = `hook:ch${pad3(args.chapter)}`;
|
|
531
|
+
const baseCreatedAt = existing ? existing.created_at : now;
|
|
532
|
+
const prevStatus = existing ? existing.status : "open";
|
|
533
|
+
const prevFulfilled = existing ? existing.fulfilled_chapter : null;
|
|
534
|
+
const prevLinks = existing ? normalizeLinks(existing.links) : null;
|
|
535
|
+
const prevHistory = existing && Array.isArray(existing.history) ? existing.history : [];
|
|
536
|
+
const existingPromiseText = existing ? safeString(existing.promise_text) : null;
|
|
537
|
+
const existingEvidence = existing ? safeString(existing.evidence_snippet) : null;
|
|
538
|
+
const existingWindow = existing ? safeWindowAfterChapter(existing.fulfillment_window, args.chapter) : null;
|
|
539
|
+
const needsWindowBackfill = existing ? existing._needs_window_backfill === true : false;
|
|
540
|
+
const computedWindow = [args.chapter + 1, args.chapter + args.policy.fulfillment_window_chapters];
|
|
541
|
+
const strengthFromEval = hookStrength !== null && hookStrength >= 1 && hookStrength <= 5 ? hookStrength : null;
|
|
542
|
+
const strengthFromExisting = existing && existing.hook_strength >= 1 && existing.hook_strength <= 5 ? existing.hook_strength : null;
|
|
543
|
+
const hook_strength = strengthFromEval ?? strengthFromExisting ?? 3;
|
|
544
|
+
const existingDefaultPromiseText = existing ? hookPromiseText(existing.hook_type) : null;
|
|
545
|
+
const promise_text = existingPromiseText === null || (existingDefaultPromiseText !== null && existingPromiseText === existingDefaultPromiseText)
|
|
546
|
+
? hookPromiseText(hookType)
|
|
547
|
+
: existingPromiseText;
|
|
548
|
+
const fulfillment_window = existingWindow && !needsWindowBackfill ? existingWindow : computedWindow;
|
|
549
|
+
const evidenceFromEval = hookEvidence ? snippet(hookEvidence, 120) : null;
|
|
550
|
+
const evidence_snippet = evidenceFromEval ?? existingEvidence ?? null;
|
|
551
|
+
const nextHistory = prevHistory ? prevHistory.slice() : [];
|
|
552
|
+
if (!existing) {
|
|
553
|
+
nextHistory.push({ at: now, chapter: args.chapter, action: "opened" });
|
|
554
|
+
}
|
|
555
|
+
else {
|
|
556
|
+
const changed = existing.hook_type !== hookType ||
|
|
557
|
+
existing.hook_strength !== hook_strength ||
|
|
558
|
+
(existingPromiseText !== null && existingPromiseText !== promise_text) ||
|
|
559
|
+
(existingWindow !== null && (existingWindow[0] !== fulfillment_window[0] || existingWindow[1] !== fulfillment_window[1])) ||
|
|
560
|
+
existingEvidence !== evidence_snippet;
|
|
561
|
+
if (changed)
|
|
562
|
+
nextHistory.push({ at: now, chapter: args.chapter, action: "updated_from_eval" });
|
|
563
|
+
}
|
|
564
|
+
const evalPath = safeString(args.evalRelPath);
|
|
565
|
+
const nextSources = evalPath ? { eval_path: evalPath } : undefined;
|
|
566
|
+
entry = {
|
|
567
|
+
...(existing ? { ...existing } : {}),
|
|
568
|
+
id: existing ? existing.id : id,
|
|
569
|
+
chapter: args.chapter,
|
|
570
|
+
hook_type: hookType,
|
|
571
|
+
hook_strength,
|
|
572
|
+
promise_text,
|
|
573
|
+
status: prevStatus,
|
|
574
|
+
fulfillment_window,
|
|
575
|
+
fulfilled_chapter: prevStatus === "fulfilled" ? prevFulfilled : null,
|
|
576
|
+
created_at: baseCreatedAt,
|
|
577
|
+
updated_at: now,
|
|
578
|
+
...(evidence_snippet ? { evidence_snippet } : {}),
|
|
579
|
+
...(nextSources ? { sources: nextSources } : {}),
|
|
580
|
+
...(prevLinks ? { links: prevLinks } : {}),
|
|
581
|
+
...(nextHistory.length > 0 ? { history: nextHistory } : {})
|
|
582
|
+
};
|
|
583
|
+
// Upsert by chapter.
|
|
584
|
+
const idx = entries.findIndex((e) => e.chapter === args.chapter);
|
|
585
|
+
if (idx >= 0)
|
|
586
|
+
entries[idx] = entry;
|
|
587
|
+
else
|
|
588
|
+
entries.push(entry);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
else if (!hookPresent && existingAtChapter) {
|
|
592
|
+
warnings.push(`Eval indicates no hook for chapter ${args.chapter}, but hook-ledger has existing entry '${existingAtChapter.id}' (status=${existingAtChapter.status}). Entry preserved (no upsert from eval).`);
|
|
593
|
+
}
|
|
594
|
+
// Backfill windows when missing/invalid.
|
|
595
|
+
for (const e of entries) {
|
|
596
|
+
const meta = e;
|
|
597
|
+
const needsBackfill = meta._needs_window_backfill === true;
|
|
598
|
+
const window = safeWindowAfterChapter(e.fulfillment_window, e.chapter);
|
|
599
|
+
if (window && !needsBackfill)
|
|
600
|
+
continue;
|
|
601
|
+
e.fulfillment_window = [e.chapter + 1, e.chapter + args.policy.fulfillment_window_chapters];
|
|
602
|
+
e.updated_at = now;
|
|
603
|
+
if (needsBackfill)
|
|
604
|
+
delete meta._needs_window_backfill;
|
|
605
|
+
const history = Array.isArray(e.history) ? e.history : [];
|
|
606
|
+
history.push({ at: now, chapter: args.chapter, action: "window_backfilled" });
|
|
607
|
+
e.history = history;
|
|
608
|
+
}
|
|
609
|
+
// Overdue detection: open promise past its inclusive window end => lapsed.
|
|
610
|
+
const newlyLapsed = [];
|
|
611
|
+
for (const e of entries) {
|
|
612
|
+
if (e.status !== "open")
|
|
613
|
+
continue;
|
|
614
|
+
const window = safeWindowAfterChapter(e.fulfillment_window, e.chapter);
|
|
615
|
+
if (!window)
|
|
616
|
+
continue;
|
|
617
|
+
const windowEnd = window[1];
|
|
618
|
+
if (args.chapter <= windowEnd)
|
|
619
|
+
continue;
|
|
620
|
+
e.status = "lapsed";
|
|
621
|
+
e.fulfilled_chapter = null;
|
|
622
|
+
e.updated_at = now;
|
|
623
|
+
const history = Array.isArray(e.history) ? e.history : [];
|
|
624
|
+
history.push({ at: now, chapter: args.chapter, action: "lapsed", detail: `overdue after ch${pad3(windowEnd)}` });
|
|
625
|
+
e.history = history;
|
|
626
|
+
newlyLapsed.push(e);
|
|
627
|
+
}
|
|
628
|
+
const updatedLedger = {
|
|
629
|
+
$schema: "schemas/hook-ledger.schema.json",
|
|
630
|
+
schema_version: 1,
|
|
631
|
+
entries: entries.sort((a, b) => a.chapter - b.chapter || a.id.localeCompare(b.id, "en")),
|
|
632
|
+
...ledgerComments
|
|
633
|
+
};
|
|
634
|
+
// Diversity window computed over last N chapters (based on available ledger entries).
|
|
635
|
+
const diversityStart = Math.max(1, args.chapter - args.policy.diversity_window_chapters + 1);
|
|
636
|
+
const diversityEnd = args.chapter;
|
|
637
|
+
const byChap = new Map();
|
|
638
|
+
for (const e of updatedLedger.entries)
|
|
639
|
+
byChap.set(e.chapter, e);
|
|
640
|
+
const typesByChapter = [];
|
|
641
|
+
for (let c = diversityStart; c <= diversityEnd; c += 1) {
|
|
642
|
+
const e = byChap.get(c) ?? null;
|
|
643
|
+
const t = e ? e.hook_type : null;
|
|
644
|
+
typesByChapter.push({ chapter: c, hook_type: t });
|
|
645
|
+
}
|
|
646
|
+
const distinctTypes = new Set();
|
|
647
|
+
let hooksInWindow = 0;
|
|
648
|
+
for (const it of typesByChapter) {
|
|
649
|
+
const t = it.hook_type;
|
|
650
|
+
if (!t || t === "none" || t === "unknown")
|
|
651
|
+
continue;
|
|
652
|
+
hooksInWindow += 1;
|
|
653
|
+
distinctTypes.add(t);
|
|
654
|
+
}
|
|
655
|
+
const maxStreak = computeMaxSameTypeStreak(typesByChapter);
|
|
656
|
+
const issues = [];
|
|
657
|
+
const lapsedEntries = updatedLedger.entries.filter((e) => e.status === "lapsed");
|
|
658
|
+
if (lapsedEntries.length > 0) {
|
|
659
|
+
const sample = newlyLapsed[0] ?? lapsedEntries[0];
|
|
660
|
+
const sev = args.policy.overdue_policy;
|
|
661
|
+
const newlySuffix = newlyLapsed.length > 0 ? ` (${newlyLapsed.length} newly lapsed)` : "";
|
|
662
|
+
issues.push({
|
|
663
|
+
id: "retention.hook_ledger.hook_debt",
|
|
664
|
+
severity: sev,
|
|
665
|
+
summary: `Hook debt outstanding: ${lapsedEntries.length} promise(s) lapsed${newlySuffix}.`,
|
|
666
|
+
evidence: sample ? `e.g. ${sample.id} (ch${pad3(sample.chapter)} window ${sample.fulfillment_window[0]}-${sample.fulfillment_window[1]})` : undefined,
|
|
667
|
+
suggestion: "Fulfill promises within the configured window, or mark fulfilled in hook-ledger.json when paid off."
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
if (maxStreak.max > args.policy.max_same_type_streak) {
|
|
671
|
+
issues.push({
|
|
672
|
+
id: "retention.hook_ledger.diversity.streak_exceeded",
|
|
673
|
+
severity: "warn",
|
|
674
|
+
summary: `Hook type streak ${maxStreak.max} exceeds max ${args.policy.max_same_type_streak} in the last ${args.policy.diversity_window_chapters} chapters.`,
|
|
675
|
+
evidence: maxStreak.type ? `type=${maxStreak.type}` : undefined,
|
|
676
|
+
suggestion: "Rotate hook types across consecutive chapters to reduce reader fatigue."
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
if (hooksInWindow > 0 && distinctTypes.size < args.policy.min_distinct_types_in_window) {
|
|
680
|
+
issues.push({
|
|
681
|
+
id: "retention.hook_ledger.diversity.low_distinct_types",
|
|
682
|
+
severity: "warn",
|
|
683
|
+
summary: `Low hook type diversity: ${distinctTypes.size} distinct type(s) in the last ${args.policy.diversity_window_chapters} chapters (min ${args.policy.min_distinct_types_in_window}).`,
|
|
684
|
+
suggestion: "Introduce at least one additional hook type within the diversity window."
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
const open = updatedLedger.entries.filter((e) => e.status === "open").map(summarizeEntry);
|
|
688
|
+
const lapsed = updatedLedger.entries.filter((e) => e.status === "lapsed").map(summarizeEntry);
|
|
689
|
+
const hasBlocking = issues.some((i) => i.severity === "hard");
|
|
690
|
+
const report = {
|
|
691
|
+
schema_version: 1,
|
|
692
|
+
generated_at: now,
|
|
693
|
+
as_of: { chapter: args.chapter, volume: args.volume },
|
|
694
|
+
scope: { volume: args.volume, chapter_start: args.reportRange.start, chapter_end: args.reportRange.end },
|
|
695
|
+
policy: args.policy,
|
|
696
|
+
ledger_path: "hook-ledger.json",
|
|
697
|
+
stats: {
|
|
698
|
+
entries_total: updatedLedger.entries.length,
|
|
699
|
+
open_total: open.length,
|
|
700
|
+
fulfilled_total: updatedLedger.entries.filter((e) => e.status === "fulfilled").length,
|
|
701
|
+
lapsed_total: lapsed.length
|
|
702
|
+
},
|
|
703
|
+
debt: {
|
|
704
|
+
newly_lapsed_total: newlyLapsed.length,
|
|
705
|
+
open,
|
|
706
|
+
lapsed
|
|
707
|
+
},
|
|
708
|
+
diversity: {
|
|
709
|
+
window_chapters: args.policy.diversity_window_chapters,
|
|
710
|
+
range: { start: diversityStart, end: diversityEnd },
|
|
711
|
+
distinct_types_in_window: distinctTypes.size,
|
|
712
|
+
min_distinct_types_in_window: args.policy.min_distinct_types_in_window,
|
|
713
|
+
max_same_type_streak_in_window: maxStreak.max,
|
|
714
|
+
max_same_type_streak_allowed: args.policy.max_same_type_streak,
|
|
715
|
+
types_by_chapter: typesByChapter
|
|
716
|
+
},
|
|
717
|
+
issues,
|
|
718
|
+
has_blocking_issues: hasBlocking
|
|
719
|
+
};
|
|
720
|
+
return { updatedLedger, entry, report, newlyLapsed, warnings };
|
|
721
|
+
}
|