cctally 1.95.4 → 1.96.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.
package/bin/_lib_share.py CHANGED
@@ -17,7 +17,7 @@ import re
17
17
  import unicodedata
18
18
  from collections.abc import Callable, Mapping, Sequence
19
19
  from dataclasses import dataclass, field
20
- from datetime import datetime, timezone
20
+ from datetime import datetime, timedelta, timezone
21
21
  from typing import Literal
22
22
  from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
23
23
 
@@ -122,12 +122,19 @@ class PeriodSpec:
122
122
  `display_tz`. True means they are ALREADY civil calendar labels — a
123
123
  `daily` bucket named `2026-05-04`, lifted to a UTC-midnight sentinel —
124
124
  and converting one shifts it by a day in every zone west of UTC.
125
+
126
+ `stated_*_date` are effective-rendering overrides, not builder inputs.
127
+ They let the provenance strip retain the civil dates found in rendered
128
+ text when an absolute frontmatter bound must extend across a timezone
129
+ boundary to contain a displayed UTC instant (#528).
125
130
  """
126
131
  start: datetime
127
132
  end: datetime
128
133
  display_tz: str
129
134
  label: str
130
135
  civil_bucket: bool = False
136
+ stated_start_date: str | None = None
137
+ stated_end_date: str | None = None
131
138
 
132
139
 
133
140
  def period_civil_dates(period: PeriodSpec) -> tuple[str, str]:
@@ -138,7 +145,8 @@ def period_civil_dates(period: PeriodSpec) -> tuple[str, str]:
138
145
  builder sets a SEMANTIC label (`This week`, `Last 8 weeks`, `Recent
139
146
  sessions`), and none of them names a date.
140
147
 
141
- A `civil_bucket` period is returned verbatim, because its boundaries
148
+ Effective-rendering overrides win when present. A `civil_bucket` period
149
+ is otherwise returned verbatim, because its boundaries
142
150
  are already calendar labels; anything else is converted into the
143
151
  labelled zone, because its boundaries are instants.
144
152
 
@@ -148,13 +156,20 @@ def period_civil_dates(period: PeriodSpec) -> tuple[str, str]:
148
156
  date rather than an outage.
149
157
  """
150
158
  if period.civil_bucket:
151
- return period.start.date().isoformat(), period.end.date().isoformat()
159
+ derived = (period.start.date().isoformat(),
160
+ period.end.date().isoformat())
161
+ return (period.stated_start_date or derived[0],
162
+ period.stated_end_date or derived[1])
152
163
  try:
153
164
  zone = ZoneInfo(period.display_tz)
154
165
  except (ZoneInfoNotFoundError, ValueError, OSError):
155
- return period.start.date().isoformat(), period.end.date().isoformat()
156
- return (period.start.astimezone(zone).date().isoformat(),
157
- period.end.astimezone(zone).date().isoformat())
166
+ derived = (period.start.date().isoformat(),
167
+ period.end.date().isoformat())
168
+ else:
169
+ derived = (period.start.astimezone(zone).date().isoformat(),
170
+ period.end.astimezone(zone).date().isoformat())
171
+ return (period.stated_start_date or derived[0],
172
+ period.stated_end_date or derived[1])
158
173
 
159
174
 
160
175
  # --- Chart primitives ---
@@ -510,6 +525,13 @@ _HBAR_LABEL_PAD = 4.0 # gap between a gutter label and its bar
510
525
  _HBAR_VALUE_PAD = 4.0 # gap between a bar's end and its value label
511
526
  _HBAR_LABEL_FONT = 11.0
512
527
  _HBAR_VALUE_FONT = 10.0
528
+ _HBAR_VERTICAL_PADDING = 12.0
529
+ _HBAR_COMFORTABLE_ROW_PITCH = 14.0
530
+
531
+ _STACKED_LEGEND_TOP_PAD = 4.0
532
+ _STACKED_LEGEND_ROW_H = 12.0
533
+ _STACKED_LEGEND_BOTTOM_PAD = 4.0
534
+ _STACKED_PLOT_MIN_H = 80.0
513
535
 
514
536
  # Distance from the plot's left edge back to the end-anchored y-axis
515
537
  # label. With no reservation for the label's own width, `projected %`
@@ -576,6 +598,17 @@ def _hbar_right_reserve(points) -> float:
576
598
  return _HBAR_VALUE_PAD + widest + _HBAR_RIGHT_PAD
577
599
 
578
600
 
601
+ def _stacked_legend_height(series_count: int) -> float:
602
+ """Height reserved above stacked bars for their model key."""
603
+ if series_count <= 0:
604
+ return 0.0
605
+ return (
606
+ _STACKED_LEGEND_TOP_PAD
607
+ + series_count * _STACKED_LEGEND_ROW_H
608
+ + _STACKED_LEGEND_BOTTOM_PAD
609
+ )
610
+
611
+
579
612
  def chart_required_width(chart: "ChartSpec | None", *,
580
613
  nominal_width: float) -> float:
581
614
  """The canvas width this chart needs so no label leaves the plot.
@@ -808,16 +841,21 @@ def _render_bar_chart_svg(chart: BarChart, *, palette: dict,
808
841
  if not pts:
809
842
  return _render_chart_no_data(palette, x=x, y=y, width=width, height=height)
810
843
 
811
- n = len(pts)
812
- bar_gap = 4.0
813
- total_gap = bar_gap * (n - 1) if n > 1 else 0.0
814
- bar_w = max(2.0, (iw - total_gap) / n)
815
-
816
844
  has_stacks = bool(chart.stacks)
817
845
  # Sorted keys give deterministic stack ordering; matches the
818
846
  # `sorted(all_model_keys)` ordering builders use for table columns,
819
847
  # so legend swatch -> table column line up by position.
820
848
  series_keys = sorted(chart.stacks.keys()) if has_stacks else []
849
+ legend_iy = iy
850
+ if has_stacks:
851
+ legend_h = _stacked_legend_height(len(series_keys))
852
+ iy += legend_h
853
+ ih -= legend_h
854
+
855
+ n = len(pts)
856
+ bar_gap = 4.0
857
+ total_gap = bar_gap * (n - 1) if n > 1 else 0.0
858
+ bar_w = max(2.0, (iw - total_gap) / n)
821
859
 
822
860
  if has_stacks:
823
861
  per_bar_totals: list[float] = []
@@ -877,18 +915,19 @@ def _render_bar_chart_svg(chart: BarChart, *, palette: dict,
877
915
  font_size=10, fill=palette["muted"],
878
916
  anchor="middle"))
879
917
 
880
- # Legend (top-right of inner box, only when stacks are present).
881
- # SVG is the only artifact where the table doesn't double as a key, so
882
- # the legend matters most for `--format svg` output. Placed inside the
883
- # inner box so total chart dimensions stay byte-stable.
918
+ # Legend (top-right of a band reserved above the plot when stacks are
919
+ # present). SVG is the only artifact where the table does not double as a
920
+ # key, so the legend must remain legible even when a bar reaches y_max.
884
921
  if has_stacks:
885
922
  legend_swatch_w = 8.0
886
923
  legend_swatch_h = 8.0
887
- legend_row_h = 12.0
888
924
  legend_col_w = 160.0
889
925
  legend_left = ix + iw - legend_col_w
890
926
  for k_idx, k in enumerate(series_keys):
891
- row_y = iy + 4 + k_idx * legend_row_h
927
+ row_y = (
928
+ legend_iy + _STACKED_LEGEND_TOP_PAD
929
+ + k_idx * _STACKED_LEGEND_ROW_H
930
+ )
892
931
  color = series_palette[k_idx % len(series_palette)]
893
932
  elements.append(svg_rect(
894
933
  legend_left, row_y, legend_swatch_w, legend_swatch_h,
@@ -2271,6 +2310,10 @@ _DISPLAYED_DATE_RE = re.compile(
2271
2310
  r"^(\d{4}-\d{2})(-\d{2})?"
2272
2311
  r"(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?$")
2273
2312
 
2313
+ _DISPLAYED_INSTANT_RE = re.compile(
2314
+ r"^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}"
2315
+ r"(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})$")
2316
+
2274
2317
 
2275
2318
  def _displayed_date_token(text: str) -> "str | None":
2276
2319
  """`YYYY-MM` or `YYYY-MM-DD` when `text` is entirely a date."""
@@ -2280,6 +2323,22 @@ def _displayed_date_token(text: str) -> "str | None":
2280
2323
  return match.group(1) + (match.group(2) or "")
2281
2324
 
2282
2325
 
2326
+ def _displayed_instant(text: str) -> "datetime | None":
2327
+ """An aware datetime when the whole rendered value is an ISO instant."""
2328
+ raw = text.strip()
2329
+ if _DISPLAYED_INSTANT_RE.match(raw) is None:
2330
+ return None
2331
+ if raw.endswith("Z"):
2332
+ raw = raw[:-1] + "+00:00"
2333
+ try:
2334
+ value = datetime.fromisoformat(raw)
2335
+ except ValueError:
2336
+ return None
2337
+ if value.tzinfo is None or value.utcoffset() is None:
2338
+ return None
2339
+ return value
2340
+
2341
+
2283
2342
  def _chart_points(chart) -> list:
2284
2343
  """Every point a chart draws — primary series, rays and stacks.
2285
2344
 
@@ -2311,8 +2370,24 @@ def _chart_points(chart) -> list:
2311
2370
  return points
2312
2371
 
2313
2372
 
2373
+ def _displayed_temporal_texts(snap: ShareSnapshot, *, shows_chart: bool,
2374
+ shows_table: bool) -> list[str]:
2375
+ """Rendered table/chart strings that can carry dates or instants."""
2376
+ values: list[str] = []
2377
+ if shows_table and _has_table(snap):
2378
+ values.extend(column.label or "" for column in snap.columns)
2379
+ for row in snap.rows:
2380
+ for column in snap.columns:
2381
+ cell = row.cells.get(column.key)
2382
+ if cell is not None:
2383
+ values.append(_render_cell_text(cell))
2384
+ if shows_chart:
2385
+ values.extend(point.x_label or "" for point in _chart_points(snap.chart))
2386
+ return values
2387
+
2388
+
2314
2389
  def displayed_dates(snap: ShareSnapshot, *, shows_chart: bool,
2315
- shows_table: bool) -> list:
2390
+ shows_table: bool) -> list[str]:
2316
2391
  """Every date this rendering DISPLAYS, as a `YYYY-MM[-DD]` token.
2317
2392
 
2318
2393
  Read from the text the renderers themselves print — `_render_cell_text`
@@ -2325,26 +2400,17 @@ def displayed_dates(snap: ShareSnapshot, *, shows_chart: bool,
2325
2400
  Only cells reachable through `snap.columns` are visited, because only
2326
2401
  those are rendered.
2327
2402
  """
2328
- tokens: list[str] = []
2329
- if shows_table and _has_table(snap):
2330
- for column in snap.columns:
2331
- token = _displayed_date_token(column.label or "")
2332
- if token:
2333
- tokens.append(token)
2334
- for row in snap.rows:
2335
- for column in snap.columns:
2336
- cell = row.cells.get(column.key)
2337
- if cell is None:
2338
- continue
2339
- token = _displayed_date_token(_render_cell_text(cell))
2340
- if token:
2341
- tokens.append(token)
2342
- if shows_chart:
2343
- for point in _chart_points(snap.chart):
2344
- token = _displayed_date_token(point.x_label or "")
2345
- if token:
2346
- tokens.append(token)
2347
- return tokens
2403
+ return [token for text in _displayed_temporal_texts(
2404
+ snap, shows_chart=shows_chart, shows_table=shows_table)
2405
+ if (token := _displayed_date_token(text)) is not None]
2406
+
2407
+
2408
+ def displayed_instants(snap: ShareSnapshot, *, shows_chart: bool,
2409
+ shows_table: bool) -> list[datetime]:
2410
+ """Every absolute ISO instant this rendering displays verbatim."""
2411
+ return [instant for text in _displayed_temporal_texts(
2412
+ snap, shows_chart=shows_chart, shows_table=shows_table)
2413
+ if (instant := _displayed_instant(text)) is not None]
2348
2414
 
2349
2415
 
2350
2416
  def _period_boundary_at(period: PeriodSpec, iso_date: str, *,
@@ -2429,22 +2495,40 @@ def effective_period(snap: ShareSnapshot, *, shows_chart: bool,
2429
2495
  period = snap.period
2430
2496
  tokens = displayed_dates(snap, shows_chart=shows_chart,
2431
2497
  shows_table=shows_table)
2432
- if not tokens:
2498
+ instants = (displayed_instants(snap, shows_chart=shows_chart,
2499
+ shows_table=shows_table)
2500
+ if not period.civil_bucket else [])
2501
+ if not tokens and not instants:
2433
2502
  return period
2434
2503
  start_civil, end_civil = period_civil_dates(period)
2435
2504
  # Compared at the token's OWN precision, so a `2026-05` month bucket
2436
2505
  # is covered by any period whose bounds fall in that month.
2437
2506
  below = [t for t in tokens if t < start_civil[:len(t)]]
2438
2507
  above = [t for t in tokens if t > end_civil[:len(t)]]
2439
- if not below and not above:
2440
- return period
2508
+ stated_start = (_whole_date(min(below), side="start")
2509
+ if below else start_civil)
2510
+ stated_end = (_whole_date(max(above), side="end")
2511
+ if above else end_civil)
2441
2512
  start = (_period_boundary_at(period, _whole_date(min(below), side="start"),
2442
2513
  side="start")
2443
2514
  if below else period.start)
2444
2515
  end = (_period_boundary_at(period, _whole_date(max(above), side="end"),
2445
2516
  side="end")
2446
2517
  if above else period.end)
2447
- return dataclasses.replace(period, start=start, end=end)
2518
+ if instants:
2519
+ start = min(start, min(instants))
2520
+ end = max(end, max(instants))
2521
+ # Frontmatter is intentionally second-precision. Its formatter
2522
+ # floors microseconds, which is safe for a start but would move an
2523
+ # end before a displayed fractional-second instant. Ceiling the
2524
+ # effective end to the next representable serialized second.
2525
+ if end.microsecond:
2526
+ end = end.replace(microsecond=0) + timedelta(seconds=1)
2527
+ if start == period.start and end == period.end:
2528
+ return period
2529
+ return dataclasses.replace(
2530
+ period, start=start, end=end,
2531
+ stated_start_date=stated_start, stated_end_date=stated_end)
2448
2532
 
2449
2533
 
2450
2534
  def _md_effective_period(snap: ShareSnapshot) -> PeriodSpec:
@@ -2675,9 +2759,45 @@ _SVG_SECTION_GAP = 20.0
2675
2759
  # 48px band) and footer (10pt at a +18 baseline inside a 30px band).
2676
2760
  _SVG_COMPOSITE_HEADER_H = 48.0
2677
2761
  _SVG_COMPOSITE_TITLE_BASELINE = 30.0
2762
+ _SVG_COMPOSITE_ALIAS_SCOPE_EXTRA_H = 20.0
2763
+ _SVG_COMPOSITE_ALIAS_SCOPE_BASELINE = 52.0
2678
2764
  _SVG_COMPOSITE_FOOTER_H = 30.0
2679
2765
  _SVG_COMPOSITE_FOOTER_BASELINE = 18.0
2680
2766
 
2767
+ _COMPOSITE_ALIAS_SCOPE_TEXT = "Project aliases are shared across sections."
2768
+
2769
+
2770
+ def chart_required_height(chart: "ChartSpec | None", *,
2771
+ nominal_height: float) -> float:
2772
+ """Return the chart slot height needed for non-crowded chart content.
2773
+
2774
+ The fixed 220px slot remains byte-stable for the historical hbar estate,
2775
+ including the CLI's 15-row cap. Detail templates may intentionally carry
2776
+ more rows, so once the nominal slot's rounded comfortable capacity is
2777
+ exceeded the canvas grows instead of compressing 11pt labels together.
2778
+ Stacked bars likewise keep a minimum plot below their reserved legend band.
2779
+ """
2780
+ if isinstance(chart, HorizontalBarChart):
2781
+ rows = len(_hbar_visible_points(chart))
2782
+ nominal_capacity = math.ceil(
2783
+ max(0.0, nominal_height - _HBAR_VERTICAL_PADDING)
2784
+ / _HBAR_COMFORTABLE_ROW_PITCH
2785
+ )
2786
+ if rows > nominal_capacity:
2787
+ return max(
2788
+ nominal_height,
2789
+ _HBAR_VERTICAL_PADDING
2790
+ + rows * _HBAR_COMFORTABLE_ROW_PITCH,
2791
+ )
2792
+ if isinstance(chart, BarChart) and chart.stacks:
2793
+ return max(
2794
+ nominal_height,
2795
+ _PADDING_TOP + _PADDING_BOTTOM
2796
+ + _stacked_legend_height(len(chart.stacks))
2797
+ + _STACKED_PLOT_MIN_H,
2798
+ )
2799
+ return nominal_height
2800
+
2681
2801
  # --- SVG table geometry (issue #38) ---
2682
2802
  _SVG_TABLE_FONT = 11
2683
2803
  _SVG_TABLE_CELL_PAD_X = 8
@@ -3118,7 +3238,10 @@ def _render_svg(snap: ShareSnapshot, *, palette: dict,
3118
3238
  is rendered separately as a sibling element).
3119
3239
  """
3120
3240
  has_table = include_table and _has_table(snap)
3121
- chart_h = _SVG_CHART_H if snap.chart is not None else 0
3241
+ chart_h = (
3242
+ chart_required_height(snap.chart, nominal_height=_SVG_CHART_H)
3243
+ if snap.chart is not None else 0
3244
+ )
3122
3245
  header_h = _svg_header_height(snap, include_chrome=include_chrome,
3123
3246
  shows_table=has_table)
3124
3247
 
@@ -3198,17 +3321,17 @@ def _render_svg(snap: ShareSnapshot, *, palette: dict,
3198
3321
  if isinstance(snap.chart, LineChart):
3199
3322
  pieces.append(_render_line_chart_svg(
3200
3323
  snap.chart, palette=palette,
3201
- x=_SVG_PADDING, y=chart_y, width=content_w, height=_SVG_CHART_H,
3324
+ x=_SVG_PADDING, y=chart_y, width=content_w, height=chart_h,
3202
3325
  ))
3203
3326
  elif isinstance(snap.chart, BarChart):
3204
3327
  pieces.append(_render_bar_chart_svg(
3205
3328
  snap.chart, palette=palette,
3206
- x=_SVG_PADDING, y=chart_y, width=content_w, height=_SVG_CHART_H,
3329
+ x=_SVG_PADDING, y=chart_y, width=content_w, height=chart_h,
3207
3330
  ))
3208
3331
  elif isinstance(snap.chart, HorizontalBarChart):
3209
3332
  pieces.append(_render_hbar_chart_svg(
3210
3333
  snap.chart, palette=palette,
3211
- x=_SVG_PADDING, y=chart_y, width=content_w, height=_SVG_CHART_H,
3334
+ x=_SVG_PADDING, y=chart_y, width=content_w, height=chart_h,
3212
3335
  ))
3213
3336
 
3214
3337
  if has_table:
@@ -3937,9 +4060,15 @@ def _stitch_html(sections: tuple[ComposedSection, ...], *,
3937
4060
  # resolved to the user agent's default black and rendered invisible on
3938
4061
  # the dark palette's #0b0f17 background. Every other element in both
3939
4062
  # the stitcher and the fragment carries an explicit inline colour.
4063
+ alias_scope = (
4064
+ f'<p class="composite-alias-scope" '
4065
+ f'style="color:{palette["muted"]};font-size:12px;margin:0 0 18px">'
4066
+ f'{_COMPOSITE_ALIAS_SCOPE_TEXT}</p>'
4067
+ if not opts.reveal_projects else ""
4068
+ )
3940
4069
  header = (
3941
4070
  f'<header><h1 style="color:{palette["fg"]}">'
3942
- f'{_xml_escape(opts.title)}</h1></header>'
4071
+ f'{_xml_escape(opts.title)}</h1>{alias_scope}</header>'
3943
4072
  )
3944
4073
  blocks = []
3945
4074
  for sec in sections:
@@ -4019,6 +4148,8 @@ def _stitch_md(sections: tuple[ComposedSection, ...], *,
4019
4148
  # otherwise inline HTML or MD specials in a user-entered title
4020
4149
  # would survive into the export unescaped.
4021
4150
  parts.append(f"# {_md_escape(opts.title)}\n\n")
4151
+ if not opts.reveal_projects:
4152
+ parts.append(f"_{_COMPOSITE_ALIAS_SCOPE_TEXT}_\n\n")
4022
4153
  last_idx = len(sections) - 1
4023
4154
  for idx, sec in enumerate(sections):
4024
4155
  # The section heading is the FRAGMENT's own heading, rendered at
@@ -4057,6 +4188,7 @@ def _stitch_svg(sections: tuple[ComposedSection, ...], *,
4057
4188
  palette=palette, branding=False)
4058
4189
  inners.append((inner, w, h))
4059
4190
  footer_text = _attribution_text(sections[0].snap.version)
4191
+ alias_scope = None if opts.reveal_projects else _COMPOSITE_ALIAS_SCOPE_TEXT
4060
4192
  # The composite title and footer CONTRIBUTE to the width (#503 S2
4061
4193
  # review F8). It used to be the section maximum alone, so an 18pt
4062
4194
  # title longer than the widest section ran off the viewBox — the
@@ -4064,12 +4196,17 @@ def _stitch_svg(sections: tuple[ComposedSection, ...], *,
4064
4196
  total_w = max(
4065
4197
  max(w for _, w, _ in inners),
4066
4198
  _SVG_PADDING * 2 + _svg_text_width(opts.title, 18.0),
4199
+ 0.0 if alias_scope is None
4200
+ else _SVG_PADDING * 2 + _svg_text_width(alias_scope, 11.0),
4067
4201
  0.0 if opts.no_branding
4068
4202
  else _SVG_PADDING * 2 + _svg_text_width(footer_text, 10.0),
4069
4203
  )
4070
4204
  stack_h = (sum(h for _, _, h in inners)
4071
4205
  + _SVG_SECTION_GAP * (len(inners) - 1))
4072
- header_h = _SVG_COMPOSITE_HEADER_H
4206
+ header_h = (
4207
+ _SVG_COMPOSITE_HEADER_H
4208
+ + (_SVG_COMPOSITE_ALIAS_SCOPE_EXTRA_H if alias_scope else 0.0)
4209
+ )
4073
4210
  footer_h = 0.0 if opts.no_branding else _SVG_COMPOSITE_FOOTER_H
4074
4211
  total_h = header_h + stack_h + footer_h
4075
4212
 
@@ -4078,6 +4215,12 @@ def _stitch_svg(sections: tuple[ComposedSection, ...], *,
4078
4215
  svg_group([
4079
4216
  svg_text(_SVG_PADDING, _SVG_COMPOSITE_TITLE_BASELINE, opts.title,
4080
4217
  font_size=18, fill=palette["fg"], weight="bold"),
4218
+ *([] if alias_scope is None else [
4219
+ svg_text(
4220
+ _SVG_PADDING, _SVG_COMPOSITE_ALIAS_SCOPE_BASELINE,
4221
+ alias_scope, font_size=11, fill=palette["muted"],
4222
+ ),
4223
+ ]),
4081
4224
  ]),
4082
4225
  ]
4083
4226
  y = header_h
package/bin/cctally CHANGED
@@ -320,6 +320,10 @@ STATUSLINE_SELECTED_PATH = _cctally_core.STATUSLINE_SELECTED_PATH
320
320
  STATUSLINE_TRANSPORT_MARKER_PATH = _cctally_core.STATUSLINE_TRANSPORT_MARKER_PATH
321
321
  STATUSLINE_AUTHORITATIVE_7D_PATH = _cctally_core.STATUSLINE_AUTHORITATIVE_7D_PATH
322
322
  STATUSLINE_AUTHORITATIVE_5H_PATH = _cctally_core.STATUSLINE_AUTHORITATIVE_5H_PATH
323
+ # Host-global, NOT APP_DIR-derived (#529 S4). Readers resolve it through
324
+ # _cctally_core at call time; this re-export exists for introspection parity
325
+ # with the other statusline constants.
326
+ STATUSLINE_OAUTH_CACHE_PATH = _cctally_core.STATUSLINE_OAUTH_CACHE_PATH
323
327
  OAUTH_BACKOFF_MARKER_PATH = _cctally_core.OAUTH_BACKOFF_MARKER_PATH
324
328
  STATUSLINE_CANDIDATE_TTL_SECONDS = _cctally_core.STATUSLINE_CANDIDATE_TTL_SECONDS
325
329
  STATUSLINE_CANDIDATE_FUTURE_SKEW_SECONDS = _cctally_core.STATUSLINE_CANDIDATE_FUTURE_SKEW_SECONDS
@@ -1830,7 +1834,7 @@ def _decode_escaped_cwd(dir_name: str) -> str:
1830
1834
  Claude stores JSONL files under ~/.claude/projects/<escaped-cwd>/<uuid>.jsonl
1831
1835
  where `<escaped-cwd>` replaces path separators with '-' and adds a leading
1832
1836
  '-'. This function reverses that transform:
1833
- "-Volumes-TRANSCEND-repos-foo" -> "/Volumes/TRANSCEND/repos/foo"
1837
+ "-Volumes-Scratch-repos-foo" -> "/Volumes/Scratch/repos/foo"
1834
1838
 
1835
1839
  Lossy: original path dashes become slashes. Preferred alternative at the
1836
1840
  caller site is to pull `cwd` from the JSONL itself when present.