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,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
|
+
|