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,404 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # Deterministic mobile readability linter (M7R.3 extension point).
4
+ #
5
+ # Usage:
6
+ # lint-readability.sh <chapter.md> <platform-profile.json> <chapter_no>
7
+ #
8
+ # Output:
9
+ # stdout JSON (exit 0 on success)
10
+ #
11
+ # Exit codes:
12
+ # 0 = success (valid JSON emitted to stdout)
13
+ # 1 = validation failure (bad args, missing files, invalid JSON)
14
+ # 2 = script exception (unexpected runtime error)
15
+ #
16
+ # Notes:
17
+ # - This script is designed to be stable and regression-friendly (deterministic ordering and thresholds).
18
+ # - The CLI treats this script's JSON stdout as authoritative when present.
19
+
20
+ set -euo pipefail
21
+
22
+ if [ "$#" -ne 3 ]; then
23
+ echo "Usage: lint-readability.sh <chapter.md> <platform-profile.json> <chapter_no>" >&2
24
+ exit 1
25
+ fi
26
+
27
+ chapter_path="$1"
28
+ profile_path="$2"
29
+ chapter_no="$3"
30
+
31
+ if [ ! -f "$chapter_path" ]; then
32
+ echo "lint-readability.sh: chapter file not found: $chapter_path" >&2
33
+ exit 1
34
+ fi
35
+
36
+ if [ ! -f "$profile_path" ]; then
37
+ echo "lint-readability.sh: platform profile file not found: $profile_path" >&2
38
+ exit 1
39
+ fi
40
+
41
+ if ! [[ "$chapter_no" =~ ^[0-9]+$ ]] || [ "$chapter_no" -le 0 ]; then
42
+ echo "lint-readability.sh: chapter_no must be an int >= 1" >&2
43
+ exit 1
44
+ fi
45
+
46
+ if ! command -v python3 >/dev/null 2>&1; then
47
+ echo "lint-readability.sh: python3 is required but not found" >&2
48
+ exit 2
49
+ fi
50
+
51
+ if ! python3 -c "import sys; sys.exit(0 if sys.version_info >= (3, 7) else 1)" 2>/dev/null; then
52
+ echo "lint-readability.sh: python3 >= 3.7 is required" >&2
53
+ exit 2
54
+ fi
55
+
56
+ python3 - "$chapter_path" "$profile_path" "$chapter_no" <<'PY'
57
+ import json
58
+ import math
59
+ import re
60
+ import sys
61
+ from datetime import datetime, timezone
62
+ from typing import Any, Dict, List, Optional, Tuple
63
+
64
+
65
+ def _die(msg: str, exit_code: int = 1) -> None:
66
+ sys.stderr.write(msg.rstrip() + "\n")
67
+ raise SystemExit(exit_code)
68
+
69
+
70
+ def _load_json(path: str) -> Any:
71
+ try:
72
+ with open(path, "r", encoding="utf-8") as f:
73
+ return json.load(f)
74
+ except Exception as e:
75
+ _die(f"lint-readability.sh: invalid JSON at {path}: {e}", 1)
76
+
77
+
78
+ def _read_text(path: str) -> str:
79
+ try:
80
+ with open(path, "r", encoding="utf-8") as f:
81
+ return f.read()
82
+ except Exception as e:
83
+ _die(f"lint-readability.sh: failed to read {path}: {e}", 1)
84
+
85
+
86
+ def _count_non_whitespace_chars(text: str) -> int:
87
+ compact = re.sub(r"\s+", "", text, flags=re.UNICODE)
88
+ return len(compact)
89
+
90
+
91
+ def _strip_code_fences(text: str) -> str:
92
+ # Best-effort removal of fenced code blocks to avoid counting them as prose paragraphs.
93
+ return re.sub(r"(^|\n)```[\s\S]*?\n```[ \t]*(?=\n|$)", "\n", text, flags=re.UNICODE)
94
+
95
+
96
+ def _is_atx_heading_line(line: str) -> bool:
97
+ return re.match(r"^(?:\ufeff)? {0,3}#{1,6}(?!#)\s+.*$", line, flags=re.UNICODE) is not None
98
+
99
+
100
+ def _extract_paragraphs(text: str) -> List[Dict[str, Any]]:
101
+ cleaned = _strip_code_fences(text).replace("\r\n", "\n").replace("\r", "\n")
102
+ lines = cleaned.split("\n")
103
+
104
+ out: List[Dict[str, Any]] = []
105
+ buf: List[str] = []
106
+
107
+ def flush() -> None:
108
+ nonlocal buf
109
+ if not buf:
110
+ return
111
+ raw = "\n".join(buf).rstrip()
112
+ buf = []
113
+ if not raw.strip():
114
+ return
115
+ first_line = ""
116
+ for l in raw.split("\n"):
117
+ if l.strip():
118
+ first_line = l
119
+ break
120
+ is_heading = _is_atx_heading_line(first_line)
121
+ chars = _count_non_whitespace_chars(raw)
122
+ has_dialogue = bool(re.search(r'["“”]', raw, flags=re.UNICODE))
123
+ out.append(
124
+ {
125
+ "index": len(out) + 1,
126
+ "raw": raw,
127
+ "chars": chars,
128
+ "is_heading": is_heading,
129
+ "has_dialogue": has_dialogue,
130
+ "is_single_line": "\n" not in raw,
131
+ }
132
+ )
133
+
134
+ for line in lines:
135
+ if not line.strip():
136
+ flush()
137
+ continue
138
+ buf.append(line)
139
+ flush()
140
+ return out
141
+
142
+
143
+ def _snippet(text: str, max_len: int) -> str:
144
+ s = re.sub(r"\s+", " ", text.strip(), flags=re.UNICODE)
145
+ if len(s) <= max_len:
146
+ return s
147
+ return s[: max(0, max_len - 1)] + "…"
148
+
149
+
150
+ def _require_int(v: Any, field: str) -> int:
151
+ if not isinstance(v, int) or isinstance(v, bool):
152
+ _die(f"lint-readability.sh: invalid platform-profile.json: {field} must be an int", 1)
153
+ return v
154
+
155
+
156
+ def _require_str(v: Any, field: str) -> str:
157
+ if not isinstance(v, str) or not v.strip():
158
+ _die(f"lint-readability.sh: invalid platform-profile.json: {field} must be a non-empty string", 1)
159
+ return v.strip()
160
+
161
+
162
+ def _require_bool(v: Any, field: str) -> bool:
163
+ if not isinstance(v, bool):
164
+ _die(f"lint-readability.sh: invalid platform-profile.json: {field} must be a boolean", 1)
165
+ return v
166
+
167
+
168
+ def _parse_mobile_policy(profile: Dict[str, Any]) -> Optional[Dict[str, Any]]:
169
+ readability = profile.get("readability")
170
+ if readability is None:
171
+ return None
172
+ if not isinstance(readability, dict):
173
+ _die("lint-readability.sh: invalid platform-profile.json: readability must be an object", 1)
174
+ mobile = readability.get("mobile")
175
+ if mobile is None:
176
+ return None
177
+ if not isinstance(mobile, dict):
178
+ _die("lint-readability.sh: invalid platform-profile.json: readability.mobile must be an object", 1)
179
+
180
+ enabled = _require_bool(mobile.get("enabled"), "readability.mobile.enabled")
181
+ max_paragraph_chars = _require_int(mobile.get("max_paragraph_chars"), "readability.mobile.max_paragraph_chars")
182
+ if max_paragraph_chars < 1:
183
+ _die("lint-readability.sh: invalid platform-profile.json: readability.mobile.max_paragraph_chars must be >= 1", 1)
184
+ max_consecutive = _require_int(
185
+ mobile.get("max_consecutive_exposition_paragraphs"),
186
+ "readability.mobile.max_consecutive_exposition_paragraphs",
187
+ )
188
+ if max_consecutive < 1:
189
+ _die(
190
+ "lint-readability.sh: invalid platform-profile.json: readability.mobile.max_consecutive_exposition_paragraphs must be >= 1",
191
+ 1,
192
+ )
193
+ blocking = _require_str(mobile.get("blocking_severity"), "readability.mobile.blocking_severity")
194
+ if blocking not in ("hard_only", "soft_and_hard"):
195
+ _die(
196
+ "lint-readability.sh: invalid platform-profile.json: readability.mobile.blocking_severity must be 'hard_only' or 'soft_and_hard'",
197
+ 1,
198
+ )
199
+
200
+ return {
201
+ "enabled": enabled,
202
+ "max_paragraph_chars": max_paragraph_chars,
203
+ "max_consecutive_exposition_paragraphs": max_consecutive,
204
+ "blocking_severity": blocking,
205
+ }
206
+
207
+
208
+ def _overlong_severity(chars: int, max_chars: int) -> str:
209
+ return "hard" if chars > math.ceil(max_chars * 1.5) else "soft"
210
+
211
+
212
+ def _exposition_run_severity(run_len: int, max_run: int) -> str:
213
+ return "hard" if run_len >= (max_run + 2) else "soft"
214
+
215
+
216
+ def _dialogue_dense_severity(quote_count: int, chars: int, max_chars: int) -> str:
217
+ if quote_count >= 10:
218
+ return "hard"
219
+ if chars > max_chars:
220
+ return "hard"
221
+ return "soft"
222
+
223
+
224
+ def main() -> None:
225
+ chapter_path = sys.argv[1]
226
+ profile_path = sys.argv[2]
227
+ chapter_no = int(sys.argv[3])
228
+
229
+ profile_raw = _load_json(profile_path)
230
+ if not isinstance(profile_raw, dict):
231
+ _die("lint-readability.sh: platform-profile.json must be a JSON object", 1)
232
+ profile: Dict[str, Any] = profile_raw
233
+
234
+ policy = _parse_mobile_policy(profile)
235
+ if policy is None or not policy.get("enabled", False):
236
+ # Still emit a valid report for introspection, but with no issues.
237
+ out = {
238
+ "schema_version": 1,
239
+ "generated_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
240
+ "scope": {"chapter": chapter_no},
241
+ "policy": policy
242
+ or {
243
+ "enabled": False,
244
+ "max_paragraph_chars": 1,
245
+ "max_consecutive_exposition_paragraphs": 1,
246
+ "blocking_severity": "hard_only",
247
+ },
248
+ "issues": [],
249
+ }
250
+ sys.stdout.write(json.dumps(out, ensure_ascii=False, separators=(",", ":")) + "\n")
251
+ return
252
+
253
+ chapter_text = _read_text(chapter_path)
254
+ paragraphs = _extract_paragraphs(chapter_text)
255
+
256
+ max_para = int(policy["max_paragraph_chars"])
257
+ max_expo = int(policy["max_consecutive_exposition_paragraphs"])
258
+
259
+ issues: List[Dict[str, Any]] = []
260
+
261
+ # Chapter-level quote / punctuation consistency (warn-only).
262
+ has_ascii_quotes = '"' in chapter_text
263
+ has_curly_quotes = bool(re.search(r"[“”]", chapter_text, flags=re.UNICODE))
264
+ if has_ascii_quotes and has_curly_quotes:
265
+ issues.append(
266
+ {
267
+ "id": "readability.mobile.mixed_quote_styles",
268
+ "severity": "warn",
269
+ "summary": "Mixed quote styles detected (ASCII '\"' and curly quotes “”).",
270
+ "suggestion": "Use a single quote style consistently to improve mobile readability.",
271
+ }
272
+ )
273
+
274
+ has_ascii_ellipsis = "..." in chapter_text
275
+ has_cjk_ellipsis = "……" in chapter_text
276
+ if has_ascii_ellipsis and has_cjk_ellipsis:
277
+ issues.append(
278
+ {
279
+ "id": "readability.mobile.mixed_ellipsis_styles",
280
+ "severity": "warn",
281
+ "summary": "Mixed ellipsis styles detected ('...' and '……').",
282
+ "suggestion": "Use a single ellipsis style consistently.",
283
+ }
284
+ )
285
+
286
+ punctuation_pairs = [
287
+ (",", ",", "readability.mobile.mixed_comma_styles", "Mixed comma styles detected (',' and ',')."),
288
+ (".", "。", "readability.mobile.mixed_period_styles", "Mixed period styles detected ('.' and '。')."),
289
+ ("?", "?", "readability.mobile.mixed_question_mark_styles", "Mixed question mark styles detected ('?' and '?')."),
290
+ ("!", "!", "readability.mobile.mixed_exclamation_styles", "Mixed exclamation mark styles detected ('!' and '!')."),
291
+ ]
292
+ for ascii_ch, full_ch, issue_id, summary in punctuation_pairs:
293
+ if ascii_ch in chapter_text and full_ch in chapter_text:
294
+ issues.append(
295
+ {
296
+ "id": issue_id,
297
+ "severity": "warn",
298
+ "summary": summary,
299
+ "suggestion": "Use a single punctuation width style consistently (prefer fullwidth for Chinese prose).",
300
+ }
301
+ )
302
+
303
+ # Per-paragraph checks.
304
+ for p in paragraphs:
305
+ if p.get("is_heading"):
306
+ continue
307
+ chars = int(p.get("chars") or 0)
308
+ raw = str(p.get("raw") or "")
309
+
310
+ if chars > max_para:
311
+ sev = _overlong_severity(chars, max_para)
312
+ issues.append(
313
+ {
314
+ "id": "readability.mobile.overlong_paragraph",
315
+ "severity": sev,
316
+ "summary": f"Overlong paragraph ({chars} chars > max {max_para}).",
317
+ "evidence": _snippet(raw, 140),
318
+ "suggestion": "Split the paragraph into 2–3 shorter paragraphs around actions/dialogue beats.",
319
+ "paragraph_index": int(p.get("index") or 0),
320
+ "paragraph_chars": chars,
321
+ }
322
+ )
323
+
324
+ has_dialogue = bool(p.get("has_dialogue"))
325
+ if has_dialogue and bool(p.get("is_single_line")):
326
+ quote_count = len(re.findall(r'["“”]', raw, flags=re.UNICODE))
327
+ if quote_count >= 6:
328
+ sev = _dialogue_dense_severity(quote_count, chars, max_para)
329
+ issues.append(
330
+ {
331
+ "id": "readability.mobile.dialogue_dense_paragraph",
332
+ "severity": sev,
333
+ "summary": "Dialogue-heavy paragraph may hurt mobile readability (many quotes in one paragraph).",
334
+ "evidence": _snippet(raw, 140),
335
+ "suggestion": "Split dialogue into separate paragraphs per speaker and keep each paragraph short.",
336
+ "paragraph_index": int(p.get("index") or 0),
337
+ "paragraph_chars": chars,
338
+ }
339
+ )
340
+
341
+ # Consecutive exposition blocks: consecutive non-heading paragraphs with no dialogue.
342
+ run_start = 0
343
+ run_len = 0
344
+
345
+ def flush_run() -> None:
346
+ nonlocal run_start, run_len
347
+ if run_len <= max_expo:
348
+ run_start = 0
349
+ run_len = 0
350
+ return
351
+ start_idx = run_start
352
+ end_idx = run_start + run_len - 1
353
+ sev = _exposition_run_severity(run_len, max_expo)
354
+ issues.append(
355
+ {
356
+ "id": "readability.mobile.exposition_run_too_long",
357
+ "severity": sev,
358
+ "summary": f"Too many consecutive exposition paragraphs ({run_len} > max {max_expo}).",
359
+ "evidence": f"paragraphs {start_idx}-{end_idx}",
360
+ "suggestion": "Break up exposition with dialogue/action beats, and add whitespace for mobile scanning.",
361
+ }
362
+ )
363
+ run_start = 0
364
+ run_len = 0
365
+
366
+ for p in paragraphs:
367
+ if p.get("is_heading"):
368
+ flush_run()
369
+ continue
370
+ is_exposition = not bool(p.get("has_dialogue"))
371
+ if not is_exposition:
372
+ flush_run()
373
+ continue
374
+ if run_len == 0:
375
+ run_start = int(p.get("index") or 0)
376
+ run_len += 1
377
+ flush_run()
378
+
379
+ out = {
380
+ "schema_version": 1,
381
+ "generated_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
382
+ "scope": {"chapter": chapter_no},
383
+ "policy": {
384
+ "enabled": bool(policy["enabled"]),
385
+ "max_paragraph_chars": max_para,
386
+ "max_consecutive_exposition_paragraphs": max_expo,
387
+ "blocking_severity": str(policy["blocking_severity"]),
388
+ },
389
+ "issues": issues,
390
+ }
391
+
392
+ sys.stdout.write(json.dumps(out, ensure_ascii=False, separators=(",", ":")) + "\n")
393
+
394
+
395
+ if __name__ == "__main__":
396
+ try:
397
+ main()
398
+ except SystemExit:
399
+ raise
400
+ except Exception as e:
401
+ sys.stderr.write(f"lint-readability.sh: unexpected error: {e}\n")
402
+ raise SystemExit(2)
403
+ PY
404
+
@@ -0,0 +1,252 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # Deterministic foreshadowing query (M3+ extension point).
4
+ #
5
+ # Usage:
6
+ # query-foreshadow.sh <chapter_num>
7
+ #
8
+ # Output:
9
+ # stdout JSON (exit 0 on success)
10
+ #
11
+ # Exit codes:
12
+ # 0 = success (valid JSON emitted to stdout)
13
+ # 1 = validation failure (bad args, missing files, invalid JSON/schema)
14
+ # 2 = script exception (unexpected runtime error)
15
+ #
16
+ # Notes:
17
+ # - Designed to be called from the novel project root (cwd contains .checkpoint.json).
18
+ # - Returns only a small subset of relevant foreshadowing items for the target chapter.
19
+
20
+ set -euo pipefail
21
+
22
+ if [ "$#" -ne 1 ]; then
23
+ echo "Usage: query-foreshadow.sh <chapter_num>" >&2
24
+ exit 1
25
+ fi
26
+
27
+ chapter_num_raw="$1"
28
+
29
+ if ! [[ "$chapter_num_raw" =~ ^[0-9]+$ ]]; then
30
+ echo "query-foreshadow.sh: chapter_num must be a positive integer (got: $chapter_num_raw)" >&2
31
+ exit 1
32
+ fi
33
+
34
+ chapter_num="$chapter_num_raw"
35
+
36
+ if [ "$chapter_num" -le 0 ]; then
37
+ echo "query-foreshadow.sh: chapter_num must be >= 1 (got: $chapter_num)" >&2
38
+ exit 1
39
+ fi
40
+
41
+ checkpoint_path=".checkpoint.json"
42
+ if [ ! -f "$checkpoint_path" ]; then
43
+ echo "query-foreshadow.sh: .checkpoint.json not found in cwd; run from the novel project root" >&2
44
+ exit 1
45
+ fi
46
+
47
+ if ! command -v python3 >/dev/null 2>&1; then
48
+ echo "query-foreshadow.sh: python3 is required but not found" >&2
49
+ exit 2
50
+ fi
51
+
52
+ python3 - "$chapter_num" <<'PY'
53
+ import json
54
+ import sys
55
+ from typing import Any, Dict, List, Optional, Set, Tuple
56
+
57
+
58
+ def _die(msg: str, exit_code: int = 1) -> None:
59
+ sys.stderr.write(msg.rstrip() + "\n")
60
+ raise SystemExit(exit_code)
61
+
62
+
63
+ def _load_json(path: str) -> Any:
64
+ try:
65
+ with open(path, "r", encoding="utf-8") as f:
66
+ return json.load(f)
67
+ except FileNotFoundError:
68
+ return None
69
+ except Exception as e:
70
+ _die(f"query-foreshadow.sh: invalid JSON at {path}: {e}", 1)
71
+
72
+
73
+ def _extract_items(data: Any, path: str) -> List[Dict[str, Any]]:
74
+ if data is None:
75
+ return []
76
+ if isinstance(data, list):
77
+ raw_items = data
78
+ elif isinstance(data, dict) and isinstance(data.get("foreshadowing"), list):
79
+ raw_items = data["foreshadowing"]
80
+ else:
81
+ _die(f"query-foreshadow.sh: unsupported schema at {path} (expected list or object.foreshadowing[])", 1)
82
+
83
+ items: List[Dict[str, Any]] = []
84
+ for it in raw_items:
85
+ if not isinstance(it, dict):
86
+ continue
87
+ foreshadow_id = it.get("id")
88
+ if not isinstance(foreshadow_id, str) or not foreshadow_id.strip():
89
+ continue
90
+ items.append(it)
91
+ return items
92
+
93
+
94
+ def _as_range(value: Any) -> Optional[Tuple[int, int]]:
95
+ if not isinstance(value, list) or len(value) != 2:
96
+ return None
97
+ a, b = value[0], value[1]
98
+ if not isinstance(a, int) or not isinstance(b, int):
99
+ return None
100
+ if a > b:
101
+ return None
102
+ if a < 1:
103
+ return None
104
+ return (a, b)
105
+
106
+
107
+ def _range_contains(r: Optional[Tuple[int, int]], chapter: int) -> bool:
108
+ if r is None:
109
+ return False
110
+ return r[0] <= chapter <= r[1]
111
+
112
+
113
+ def _is_overdue_short(item: Dict[str, Any], chapter: int) -> bool:
114
+ if item.get("scope") != "short":
115
+ return False
116
+ if item.get("status") == "resolved":
117
+ return False
118
+ r = _as_range(item.get("target_resolve_range"))
119
+ if r is None:
120
+ return False
121
+ return chapter > r[1]
122
+
123
+
124
+ def _is_relevant_from_plan(item: Dict[str, Any], chapter: int) -> bool:
125
+ planted_chapter = item.get("planted_chapter")
126
+ if isinstance(planted_chapter, int) and planted_chapter == chapter:
127
+ return True
128
+ return _range_contains(_as_range(item.get("target_resolve_range")), chapter)
129
+
130
+
131
+ def _is_relevant_from_global(item: Dict[str, Any], chapter: int) -> bool:
132
+ if _range_contains(_as_range(item.get("target_resolve_range")), chapter):
133
+ return True
134
+ return _is_overdue_short(item, chapter)
135
+
136
+
137
+ def _merge_missing(base: Dict[str, Any], fallback: Dict[str, Any], keys: List[str]) -> Dict[str, Any]:
138
+ out = dict(base)
139
+ for k in keys:
140
+ if k not in out or out.get(k) in (None, "", []):
141
+ if k in fallback and fallback.get(k) not in (None, "", []):
142
+ out[k] = fallback.get(k)
143
+ return out
144
+
145
+
146
+ def _normalize_item(item: Dict[str, Any]) -> Dict[str, Any]:
147
+ # Keep a stable subset of fields; pass through unknown fields is intentionally avoided
148
+ # to keep output small and regression-friendly.
149
+ out: Dict[str, Any] = {"id": item.get("id")}
150
+ for k in [
151
+ "description",
152
+ "scope",
153
+ "status",
154
+ "planted_chapter",
155
+ "planted_storyline",
156
+ "target_resolve_range",
157
+ "last_updated_chapter",
158
+ "history",
159
+ ]:
160
+ if k in item:
161
+ out[k] = item.get(k)
162
+ return out
163
+
164
+
165
+ def main() -> None:
166
+ chapter = int(sys.argv[1])
167
+
168
+ checkpoint = _load_json(".checkpoint.json")
169
+ if not isinstance(checkpoint, dict):
170
+ _die("query-foreshadow.sh: .checkpoint.json must be a JSON object", 1)
171
+ volume = checkpoint.get("current_volume")
172
+ if not isinstance(volume, int) or volume < 0:
173
+ _die("query-foreshadow.sh: .checkpoint.json.current_volume must be an int >= 0", 1)
174
+
175
+ global_path = "foreshadowing/global.json"
176
+ plan_path = f"volumes/vol-{volume:02d}/foreshadowing.json"
177
+
178
+ global_items_raw = _extract_items(_load_json(global_path), global_path)
179
+ plan_items_raw = _extract_items(_load_json(plan_path), plan_path)
180
+
181
+ global_by_id: Dict[str, Dict[str, Any]] = {str(it["id"]): it for it in global_items_raw}
182
+ plan_by_id: Dict[str, Dict[str, Any]] = {str(it["id"]): it for it in plan_items_raw}
183
+
184
+ relevant_ids: List[str] = []
185
+ relevant_from_plan = 0
186
+ relevant_from_global = 0
187
+ overdue_short = 0
188
+
189
+ seen: Set[str] = set()
190
+
191
+ for foreshadow_id, it in plan_by_id.items():
192
+ if it.get("status") == "resolved":
193
+ continue
194
+ if _is_relevant_from_plan(it, chapter):
195
+ if foreshadow_id not in seen:
196
+ seen.add(foreshadow_id)
197
+ relevant_ids.append(foreshadow_id)
198
+ relevant_from_plan += 1
199
+
200
+ for foreshadow_id, it in global_by_id.items():
201
+ if it.get("status") == "resolved":
202
+ continue
203
+ if _is_relevant_from_global(it, chapter):
204
+ if foreshadow_id not in seen:
205
+ seen.add(foreshadow_id)
206
+ relevant_ids.append(foreshadow_id)
207
+ relevant_from_global += 1
208
+ if _is_overdue_short(it, chapter):
209
+ overdue_short += 1
210
+
211
+ items: List[Dict[str, Any]] = []
212
+ for foreshadow_id in sorted(relevant_ids):
213
+ base = global_by_id.get(foreshadow_id) or plan_by_id.get(foreshadow_id) or {}
214
+ if not base:
215
+ continue
216
+ merged = base
217
+ if foreshadow_id in global_by_id and foreshadow_id in plan_by_id:
218
+ merged = _merge_missing(global_by_id[foreshadow_id], plan_by_id[foreshadow_id], ["description", "scope", "target_resolve_range"])
219
+ items.append(_normalize_item(merged))
220
+
221
+ out: Dict[str, Any] = {
222
+ "schema_version": 1,
223
+ "chapter": chapter,
224
+ "volume": volume,
225
+ "items": items, # sorted by id ascending (deterministic)
226
+ # stats.relevant_from_plan + stats.relevant_from_global may exceed stats.items
227
+ # because a single item can match relevance criteria from both sources.
228
+ "stats": {
229
+ "items": len(items),
230
+ "relevant_from_plan": relevant_from_plan,
231
+ "relevant_from_global": relevant_from_global,
232
+ "overdue_short": overdue_short,
233
+ },
234
+ "sources": {
235
+ "checkpoint": ".checkpoint.json",
236
+ "global": global_path,
237
+ "volume_plan": plan_path,
238
+ },
239
+ }
240
+
241
+ sys.stdout.write(json.dumps(out, ensure_ascii=False) + "\n")
242
+
243
+
244
+ try:
245
+ main()
246
+ except SystemExit:
247
+ raise
248
+ except Exception as e:
249
+ sys.stderr.write(f"query-foreshadow.sh: unexpected error: {e}\n")
250
+ raise SystemExit(2)
251
+ PY
252
+