cctally 1.82.0 → 1.83.0

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 (52) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/README.md +52 -74
  3. package/bin/_cctally_alerts.py +8 -1
  4. package/bin/_cctally_cache.py +963 -149
  5. package/bin/_cctally_config.py +43 -4
  6. package/bin/_cctally_core.py +933 -759
  7. package/bin/_cctally_dashboard.py +157 -47
  8. package/bin/_cctally_dashboard_cache_report.py +13 -6
  9. package/bin/_cctally_dashboard_conversation.py +1 -0
  10. package/bin/_cctally_dashboard_envelope.py +186 -8
  11. package/bin/_cctally_dashboard_share.py +60 -20
  12. package/bin/_cctally_dashboard_sources.py +427 -128
  13. package/bin/_cctally_db.py +605 -128
  14. package/bin/_cctally_doctor.py +413 -28
  15. package/bin/_cctally_five_hour.py +12 -5
  16. package/bin/_cctally_journal.py +2050 -156
  17. package/bin/_cctally_journal_repair.py +519 -0
  18. package/bin/_cctally_milestone_history.py +142 -56
  19. package/bin/_cctally_milestones.py +179 -111
  20. package/bin/_cctally_parser.py +42 -0
  21. package/bin/_cctally_project.py +24 -18
  22. package/bin/_cctally_quota.py +139 -25
  23. package/bin/_cctally_record.py +279 -108
  24. package/bin/_cctally_rederive.py +1052 -0
  25. package/bin/_cctally_reporting.py +58 -53
  26. package/bin/_cctally_setup.py +1 -0
  27. package/bin/_cctally_source_analytics.py +4 -1
  28. package/bin/_cctally_statusline.py +11 -11
  29. package/bin/_cctally_store.py +1039 -31
  30. package/bin/_cctally_sync_week.py +17 -8
  31. package/bin/_cctally_tui.py +421 -54
  32. package/bin/_cctally_update.py +133 -8
  33. package/bin/_cctally_weekrefs.py +14 -0
  34. package/bin/_lib_aggregators.py +10 -6
  35. package/bin/_lib_cache_report.py +101 -9
  36. package/bin/_lib_codex_pools.py +82 -0
  37. package/bin/_lib_conversation_query.py +126 -33
  38. package/bin/_lib_dashboard_sources.py +126 -1
  39. package/bin/_lib_diff_kernel.py +28 -15
  40. package/bin/_lib_doctor.py +342 -4
  41. package/bin/_lib_journal.py +924 -2
  42. package/bin/_lib_jsonl.py +43 -14
  43. package/bin/_lib_pricing.py +140 -21
  44. package/bin/_lib_readme_refresh.py +401 -0
  45. package/bin/_lib_rederive.py +395 -0
  46. package/bin/_lib_share.py +58 -2
  47. package/bin/cctally +56 -8
  48. package/dashboard/static/assets/{index-DJP4gEB7.js → index-3bgCMVHb.js} +52 -52
  49. package/dashboard/static/assets/index-D27EIHEI.css +1 -0
  50. package/dashboard/static/dashboard.html +2 -2
  51. package/package.json +6 -1
  52. package/dashboard/static/assets/index-Dk1nplOz.css +0 -1
@@ -0,0 +1,401 @@
1
+ #!/usr/bin/env python3
2
+ """Pure kernel for the public README auto-refresh (issue #354).
3
+
4
+ Four stdlib-only responsibilities, each a pure function so the private
5
+ release driver, a public pytest, and a `--check` CLI can all share one
6
+ implementation:
7
+
8
+ 1. ``extract_highlights`` — pull up to N lint-clean highlight bullets from a
9
+ specific ``## [X.Y.Z] - YYYY-MM-DD`` CHANGELOG section (exact version match,
10
+ Added > Changed > Fixed priority, multiline flattening, terminal issue-ref
11
+ stripping, fenced-code skipping).
12
+ 2. ``normalize_highlight`` — deterministically rewrite a bullet so it satisfies
13
+ the copy lint by construction (em/en dashes with surrounding spaces become
14
+ ``, ``; doubled separators collapse; leading/trailing separators trim).
15
+ 3. ``splice_marker_block`` — replace only the region between the HTML-comment
16
+ markers, byte-preserving everything outside (CRLF included), failing closed
17
+ on missing/duplicated/malformed markers.
18
+ 4. ``lint_copy`` — scan README prose (outside fenced code) for em dash, en
19
+ dash, a defined emoji set, and mid-line spaced-hyphen dash substitutes.
20
+
21
+ This module is PUBLIC (listed in `.mirror-allowlist` + `package.json` files[]),
22
+ because its pytest lands on the public mirror and a public test must not import
23
+ a private module. It contains nothing sensitive — Markdown parsing and linting.
24
+
25
+ CLI: ``python3 bin/_lib_readme_refresh.py --check README.md`` runs the copy lint
26
+ plus marker well-formedness and exits 0 (clean) / 1 (issues) / 2 (usage).
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import re
31
+ import sys
32
+
33
+ MARKER_BEGIN = "<!-- cctally:latest-stable:begin -->"
34
+ MARKER_END = "<!-- cctally:latest-stable:end -->"
35
+
36
+
37
+ class ChangelogSectionMissing(ValueError):
38
+ """Raised when the requested version has no exact CHANGELOG section."""
39
+
40
+
41
+ class MarkerError(ValueError):
42
+ """Raised when the latest-stable markers are missing/duplicated/malformed."""
43
+
44
+
45
+ # --------------------------------------------------------------------------
46
+ # Fence handling (shared by extraction + lint)
47
+ # --------------------------------------------------------------------------
48
+ # GFM code fences: a line whose content (after up to 3 leading spaces) begins
49
+ # with a run of >= 3 backticks or >= 3 tildes. The closing fence must use the
50
+ # same character, be at least as long as the opener, and (unlike the opener)
51
+ # carry no info string.
52
+ _FENCE_RE = re.compile(r"^[ ]{0,3}(`{3,}|~{3,})([^\n]*)$")
53
+
54
+
55
+ class _UnclosedFence(Exception):
56
+ def __init__(self, line_no: int, excerpt: str) -> None:
57
+ super().__init__(f"unclosed fence opened on line {line_no}")
58
+ self.line_no = line_no
59
+ self.excerpt = excerpt
60
+
61
+
62
+ def _fence_marker(line: str):
63
+ """Return ``(char, length, info)`` if *line* is a fence marker, else None."""
64
+ m = _FENCE_RE.match(line)
65
+ if not m:
66
+ return None
67
+ run = m.group(1)
68
+ return (run[0], len(run), m.group(2).strip())
69
+
70
+
71
+ def _iter_content_lines(text: str):
72
+ """Yield ``(line_no, line)`` for lines OUTSIDE fenced code blocks.
73
+
74
+ Raises ``_UnclosedFence`` (at the end of the walk) if a fence is opened but
75
+ never closed. Operates on ``str.split("\\n")`` so a CRLF file keeps its
76
+ trailing ``\\r`` on each line — harmless for the char scans below.
77
+ """
78
+ in_fence = False
79
+ fence_char = ""
80
+ fence_len = 0
81
+ fence_open_line = 0
82
+ fence_open_text = ""
83
+ for idx, line in enumerate(text.split("\n"), start=1):
84
+ marker = _fence_marker(line)
85
+ if in_fence:
86
+ if (
87
+ marker is not None
88
+ and marker[0] == fence_char
89
+ and marker[1] >= fence_len
90
+ and marker[2] == ""
91
+ ):
92
+ in_fence = False
93
+ continue
94
+ if marker is not None:
95
+ in_fence = True
96
+ fence_char, fence_len = marker[0], marker[1]
97
+ fence_open_line = idx
98
+ fence_open_text = line.strip()
99
+ continue
100
+ yield idx, line
101
+ if in_fence:
102
+ raise _UnclosedFence(fence_open_line, fence_open_text)
103
+
104
+
105
+ # --------------------------------------------------------------------------
106
+ # Copy lint
107
+ # --------------------------------------------------------------------------
108
+ _EM_DASH = "—"
109
+ _EN_DASH = "–"
110
+ _BULLET_PREFIX_RE = re.compile(r"^\s*[-*] ")
111
+
112
+
113
+ def _has_emoji(s: str) -> bool:
114
+ for ch in s:
115
+ cp = ord(ch)
116
+ if (
117
+ 0x1F000 <= cp <= 0x1FBFF
118
+ or 0x2600 <= cp <= 0x27BF
119
+ or 0x2B00 <= cp <= 0x2BFF
120
+ or cp == 0xFE0F
121
+ or cp == 0x200D
122
+ ):
123
+ return True
124
+ return False
125
+
126
+
127
+ def lint_copy(readme_text: str) -> "list[tuple[int, str, str]]":
128
+ """Return copy-lint issues as ``(1-based line_no, code, excerpt)`` tuples.
129
+
130
+ Empty list means clean. Codes: ``em-dash``, ``en-dash``, ``emoji``,
131
+ ``spaced-hyphen``. Scans only outside fenced code blocks; an unclosed fence
132
+ collapses to a single ``unclosed-fence`` issue.
133
+ """
134
+ try:
135
+ content = list(_iter_content_lines(readme_text))
136
+ except _UnclosedFence as uf:
137
+ return [(uf.line_no, "unclosed-fence", uf.excerpt)]
138
+
139
+ issues: "list[tuple[int, str, str]]" = []
140
+ for line_no, line in content:
141
+ excerpt = line.strip()
142
+ if _EM_DASH in line:
143
+ issues.append((line_no, "em-dash", excerpt))
144
+ if _EN_DASH in line:
145
+ issues.append((line_no, "en-dash", excerpt))
146
+ if _has_emoji(line):
147
+ issues.append((line_no, "emoji", excerpt))
148
+ # Line-leading "- "/"* " bullets are fine; only a REMAINING mid-line
149
+ # " - " is a dash substitute.
150
+ remainder = _BULLET_PREFIX_RE.sub("", line, count=1)
151
+ if " - " in remainder:
152
+ issues.append((line_no, "spaced-hyphen", excerpt))
153
+ return issues
154
+
155
+
156
+ # --------------------------------------------------------------------------
157
+ # Highlight normalization
158
+ # --------------------------------------------------------------------------
159
+ def normalize_highlight(bullet: str) -> str:
160
+ """Rewrite a bullet so it satisfies the copy lint deterministically."""
161
+ b = re.sub(r"\s*[—–]\s*", ", ", bullet)
162
+ b = re.sub(r"(, )+", ", ", b)
163
+ b = b.strip()
164
+ b = b.strip(", ")
165
+ return b.strip()
166
+
167
+
168
+ # --------------------------------------------------------------------------
169
+ # CHANGELOG extraction
170
+ # --------------------------------------------------------------------------
171
+ _ISSUE_REF_RE = re.compile(r"\s*\(#\d+[^)]*\)\s*$")
172
+ _SUBSECTIONS = (("### Added", "added"), ("### Changed", "changed"), ("### Fixed", "fixed"))
173
+ _SUBSECTION_KEYS = {name: key for name, key in _SUBSECTIONS}
174
+ _BULLET_RE = re.compile(r"^[-*] ")
175
+
176
+
177
+ def _section_heading_re(version: str) -> "re.Pattern[str]":
178
+ return re.compile(
179
+ r"^## \[" + re.escape(version) + r"\] - (\d{4}-\d{2}-\d{2})\s*$"
180
+ )
181
+
182
+
183
+ def _section_body_lines(changelog_text: str, version: str) -> "list[str]":
184
+ lines = changelog_text.split("\n")
185
+ heading_re = _section_heading_re(version)
186
+ start = None
187
+ for i, line in enumerate(lines):
188
+ if heading_re.match(line):
189
+ start = i
190
+ break
191
+ if start is None:
192
+ raise ChangelogSectionMissing(version)
193
+ end = len(lines)
194
+ for j in range(start + 1, len(lines)):
195
+ if lines[j].startswith("## "):
196
+ end = j
197
+ break
198
+ return lines[start + 1:end]
199
+
200
+
201
+ def changelog_release_date(changelog_text: str, version: str) -> str:
202
+ """Return the ``YYYY-MM-DD`` release date for *version*'s section."""
203
+ heading_re = _section_heading_re(version)
204
+ for line in changelog_text.split("\n"):
205
+ m = heading_re.match(line)
206
+ if m:
207
+ return m.group(1)
208
+ raise ChangelogSectionMissing(version)
209
+
210
+
211
+ def _collect_raw_bullets(body_lines: "list[str]") -> "dict[str, list[str]]":
212
+ """Group raw (un-normalized) bullets by subsection, flattening wraps.
213
+
214
+ Raises ``ValueError`` if a fence is opened inside the section and never
215
+ closed before the section ends.
216
+ """
217
+ buckets: "dict[str, list[str]]" = {"added": [], "changed": [], "fixed": []}
218
+ subsection: "str | None" = None
219
+ current: "str | None" = None
220
+ in_fence = False
221
+ fence_char = ""
222
+ fence_len = 0
223
+
224
+ def finalize() -> None:
225
+ nonlocal current
226
+ if current is not None and subsection in buckets:
227
+ buckets[subsection].append(current.rstrip())
228
+ current = None
229
+
230
+ for line in body_lines:
231
+ marker = _fence_marker(line)
232
+ if in_fence:
233
+ if (
234
+ marker is not None
235
+ and marker[0] == fence_char
236
+ and marker[1] >= fence_len
237
+ and marker[2] == ""
238
+ ):
239
+ in_fence = False
240
+ continue
241
+ if marker is not None:
242
+ finalize()
243
+ in_fence = True
244
+ fence_char, fence_len = marker[0], marker[1]
245
+ continue
246
+ stripped = line.rstrip()
247
+ if stripped.startswith("## "):
248
+ finalize()
249
+ subsection = None
250
+ continue
251
+ if stripped.startswith("### "):
252
+ finalize()
253
+ subsection = _SUBSECTION_KEYS.get(stripped)
254
+ continue
255
+ if _BULLET_RE.match(line):
256
+ finalize()
257
+ current = line[2:].strip() if subsection in buckets else None
258
+ continue
259
+ if not stripped:
260
+ finalize()
261
+ continue
262
+ # Continuation of the current bullet.
263
+ if current is not None:
264
+ current = current + " " + line.strip()
265
+ if in_fence:
266
+ raise ValueError("unclosed code fence inside CHANGELOG section")
267
+ finalize()
268
+ return buckets
269
+
270
+
271
+ def extract_highlights(
272
+ changelog_text: str, version: str, *, limit: int = 3
273
+ ) -> "list[str]":
274
+ """Return up to ``limit`` normalized, lint-clean highlight bullets.
275
+
276
+ Exact heading match (never prefix), Added > Changed > Fixed priority,
277
+ multiline flattening, one terminal issue-ref stripped, then normalized.
278
+ A bullet that still fails the copy lint after normalization is dropped and
279
+ the next candidate is taken. Raises ``ChangelogSectionMissing`` if the
280
+ version section is absent.
281
+ """
282
+ body = _section_body_lines(changelog_text, version)
283
+ buckets = _collect_raw_bullets(body)
284
+ candidates = buckets["added"] + buckets["changed"] + buckets["fixed"]
285
+ kept: "list[str]" = []
286
+ for raw in candidates:
287
+ if len(kept) >= limit:
288
+ break
289
+ stripped = _ISSUE_REF_RE.sub("", raw)
290
+ normalized = normalize_highlight(stripped)
291
+ if not normalized:
292
+ continue
293
+ if lint_copy("- " + normalized):
294
+ continue # drop-bullet fallback
295
+ kept.append(normalized)
296
+ return kept
297
+
298
+
299
+ def render_latest_stable_block(
300
+ version: str, date: str, bullets: "list[str]"
301
+ ) -> str:
302
+ """Render the auto-maintained latest-stable block (no trailing newline
303
+ after the last bullet; version+date only when *bullets* is empty)."""
304
+ base = f"**Latest stable: v{version}** ({date})\n"
305
+ if bullets:
306
+ return base + "\n" + "\n".join(f"- {b}" for b in bullets)
307
+ return base
308
+
309
+
310
+ # --------------------------------------------------------------------------
311
+ # Marker splice
312
+ # --------------------------------------------------------------------------
313
+ def _marker_alone_on_line(text: str, idx: int, marker: str) -> bool:
314
+ before = text[:idx]
315
+ if before and not before.endswith("\n"):
316
+ return False
317
+ tail_start = idx + len(marker)
318
+ nl = text.find("\n", tail_start)
319
+ tail = text[tail_start:] if nl == -1 else text[tail_start:nl]
320
+ return tail.strip() == ""
321
+
322
+
323
+ def _find_marker_pair(text: str) -> "tuple[int, int]":
324
+ if text.count(MARKER_BEGIN) != 1:
325
+ raise MarkerError(
326
+ f"expected exactly one {MARKER_BEGIN!r}, found "
327
+ f"{text.count(MARKER_BEGIN)}"
328
+ )
329
+ if text.count(MARKER_END) != 1:
330
+ raise MarkerError(
331
+ f"expected exactly one {MARKER_END!r}, found {text.count(MARKER_END)}"
332
+ )
333
+ begin_idx = text.index(MARKER_BEGIN)
334
+ end_idx = text.index(MARKER_END)
335
+ if begin_idx >= end_idx:
336
+ raise MarkerError("begin marker does not precede end marker")
337
+ if not _marker_alone_on_line(text, begin_idx, MARKER_BEGIN):
338
+ raise MarkerError("begin marker is not alone on its line")
339
+ if not _marker_alone_on_line(text, end_idx, MARKER_END):
340
+ raise MarkerError("end marker is not alone on its line")
341
+ return begin_idx, end_idx
342
+
343
+
344
+ def splice_marker_block(readme_text: str, block: str) -> str:
345
+ """Replace the interior between the markers with ``\\n`` + block + ``\\n``.
346
+
347
+ Every byte outside the region is preserved (CRLF included). Fails closed on
348
+ missing, duplicated, or malformed markers.
349
+ """
350
+ begin_idx, end_idx = _find_marker_pair(readme_text)
351
+ region_start = begin_idx + len(MARKER_BEGIN)
352
+ return (
353
+ readme_text[:region_start]
354
+ + "\n"
355
+ + block
356
+ + "\n"
357
+ + readme_text[end_idx:]
358
+ )
359
+
360
+
361
+ # --------------------------------------------------------------------------
362
+ # CLI (--check)
363
+ # --------------------------------------------------------------------------
364
+ def _marker_wellformed_issues(text: str) -> "list[tuple[int, str, str]]":
365
+ try:
366
+ _find_marker_pair(text)
367
+ except MarkerError as exc:
368
+ line_no = 1
369
+ if MARKER_BEGIN in text:
370
+ line_no = text[: text.index(MARKER_BEGIN)].count("\n") + 1
371
+ return [(line_no, "markers", str(exc))]
372
+ return []
373
+
374
+
375
+ def _check(path: str) -> int:
376
+ with open(path, "r", encoding="utf-8") as fh:
377
+ text = fh.read()
378
+ issues = lint_copy(text) + _marker_wellformed_issues(text)
379
+ for line_no, code, excerpt in issues:
380
+ print(f"{path}:{line_no}: {code}: {excerpt}", file=sys.stderr)
381
+ return 1 if issues else 0
382
+
383
+
384
+ def main(argv: "list[str] | None" = None) -> int:
385
+ import argparse
386
+
387
+ parser = argparse.ArgumentParser(
388
+ description="Copy-lint + marker well-formedness check for README.md.",
389
+ )
390
+ parser.add_argument(
391
+ "--check",
392
+ metavar="FILE",
393
+ required=True,
394
+ help="Markdown file to lint (copy rules + latest-stable markers).",
395
+ )
396
+ args = parser.parse_args(argv)
397
+ return _check(args.check)
398
+
399
+
400
+ if __name__ == "__main__":
401
+ sys.exit(main())