cctally 1.25.0 → 1.26.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/CHANGELOG.md CHANGED
@@ -5,6 +5,14 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.26.0] - 2026-06-03
9
+
10
+ ### Added
11
+ - **`cctally budget set --project` now hints the correct argument order when you put the amount after the flag.** Writing the flag-first form `cctally budget set --project 25` binds `25` to the `--project` value (argparse `nargs='?'`) and leaves the amount unset; instead of the generic "`set --project` requires an amount" error, cctally now points you at the supported `cctally budget set 25 --project` ordering. A bare numeric value that names a real directory (e.g. a repo literally called `2025`) is treated as a project path rather than a misplaced amount, so the hint never misfires on a numeric-named repo. The exit code is unchanged (2, a safe no-write failure) and valid invocations are unaffected.
12
+
13
+ ### Changed
14
+ - **Internal refactor (no user-facing change): consolidated the per-project budget label-disambiguation and threshold-crossing arithmetic onto two shared helpers (`_project_budget_labels`, `_project_crossings`), used by the budget table, the alert firing path, the config-write reconcile, and the dashboard SSE envelope, and replaced the share-ranking hidden-`MoneyCell` hack with an explicit `ProjectCell.rank_cost` field.** The firing-path label resolution is now lazy (resolved only when a crossing actually dispatches, off the common no-dispatch tick). Output is byte-identical — the per-project budget reconcile invariants (61/61), the budget/share goldens (26/26), and the full pytest suite are unchanged; nothing to do on upgrade.
15
+
8
16
  ## [1.25.0] - 2026-06-03
9
17
 
10
18
  ### Added
@@ -4285,20 +4285,14 @@ def _envelope_rows_project_budget(conn, descriptor, limit, severity_for) -> list
4285
4285
  (limit,),
4286
4286
  ).fetchall()
4287
4287
  c = _cctally()
4288
- resolve = c._resolve_project_key
4289
- resolver_cache: dict = {}
4290
- # Collision-aware labels ACROSS the distinct project_keys in these rows so
4291
- # two same-basename roots (/work/app + /personal/app) don't both render
4292
- # "app" byte-matching the live notification + budget table via the shared
4293
- # `_project_disambiguate_labels`. Disambiguated across the ROWS (not live
4294
- # config) to preserve the render-from-the-snapshotted-row invariant above.
4295
- distinct_keys = sorted({r["project_key"] for r in rows})
4296
- pkeys = [resolve(k, "git-root", resolver_cache) for k in distinct_keys]
4297
- disambig = c._project_disambiguate_labels([{"key": pk} for pk in pkeys])
4298
- label_by_key = {
4299
- distinct_keys[i]: disambig.get(i, pkeys[i].display_key)
4300
- for i in range(len(distinct_keys))
4301
- }
4288
+ # Collision-aware labels via the shared primitive (#130), disambiguated
4289
+ # across the alerted ROWS (NOT live config) to preserve the
4290
+ # render-from-the-snapshotted-row invariant above so a deleted/renamed
4291
+ # config key still renders. This is intentionally a different feed than the
4292
+ # table/notification (full config); see spec §1 Goals (Codex F1).
4293
+ label_by_key = c._project_budget_labels(
4294
+ sorted({r["project_key"] for r in rows})
4295
+ )
4302
4296
  out: list[dict] = []
4303
4297
  for r in rows:
4304
4298
  threshold = int(r["threshold"])
@@ -1711,6 +1711,16 @@ def _resolve_project_budget_target(raw: str):
1711
1711
  return key.git_root or key.bucket_path
1712
1712
 
1713
1713
 
1714
+ def _looks_numeric(s) -> bool:
1715
+ """True iff `s` parses as a positive finite number — used to detect the
1716
+ `budget set --project 25` footgun (#130)."""
1717
+ try:
1718
+ v = float(s)
1719
+ except (TypeError, ValueError):
1720
+ return False
1721
+ return math.isfinite(v) and v > 0
1722
+
1723
+
1714
1724
  def _cmd_budget_set_project(args: argparse.Namespace) -> int:
1715
1725
  """`cctally budget set AMOUNT --project[=PATH]` — write one entry into
1716
1726
  `budget.projects`, keyed by the resolved canonical git-root. Writes the
@@ -1718,10 +1728,26 @@ def _cmd_budget_set_project(args: argparse.Namespace) -> int:
1718
1728
  c = _cctally()
1719
1729
  raw_amount = getattr(args, "amount", None)
1720
1730
  if raw_amount is None:
1721
- eprint(
1722
- "cctally budget: `set --project` requires an amount, e.g. "
1723
- "cctally budget set 25 --project"
1724
- )
1731
+ proj = getattr(args, "project", None)
1732
+ if proj and proj != "__CWD__" and _looks_numeric(proj) and not os.path.isdir(proj):
1733
+ # `budget set --project 25` → argparse bound 25 to --project,
1734
+ # leaving amount=None (#130). A bare numeric value is almost always
1735
+ # the amount in the wrong slot — but NOT when it names a real
1736
+ # directory (e.g. a repo literally called `./2025`), which the
1737
+ # `not os.path.isdir(proj)` guard excludes so a numeric-named path
1738
+ # falls through to the generic "requires an amount" message below
1739
+ # instead of being misread as a misplaced amount. Point at the
1740
+ # right ordering.
1741
+ eprint(
1742
+ f"cctally budget: '{proj}' looks like an amount, not a "
1743
+ f"project path. Did you mean: cctally budget set {proj} "
1744
+ f"--project"
1745
+ )
1746
+ else:
1747
+ eprint(
1748
+ "cctally budget: `set --project` requires an amount, e.g. "
1749
+ "cctally budget set 25 --project"
1750
+ )
1725
1751
  return 2
1726
1752
  try:
1727
1753
  amount = float(raw_amount)
@@ -2131,26 +2157,13 @@ def _build_project_budget_rows(conn, budget_cfg, now_utc):
2131
2157
  last24h = c._sum_cost_by_project(recent_start, now_utc, mode="auto")
2132
2158
  thresholds = tuple(budget_cfg["alert_thresholds"])
2133
2159
 
2134
- # Resolve every configured key to its ProjectKey ONCE, then route the
2135
- # display labels through the shared collision-disambiguation primitive
2136
- # (`_project_disambiguate_labels`, the SAME one cmd_project's table and
2137
- # `_build_project_snapshot` use). A bare `display_key` is just the
2138
- # basename, so two distinct git-roots sharing a basename (e.g.
2139
- # `/work/app` + `/personal/app`) would BOTH render as `app` — and in
2140
- # anonymized share BOTH collapse to a single `project-1`. The primitive
2141
- # suffixes the colliding rows with their parent-dir segment
2142
- # ("app (work)" / "app (personal)"); non-colliding rows keep `display_key`.
2143
- resolver_cache: dict = {}
2144
- pkeys = [
2145
- c._resolve_project_key(key, "git-root", resolver_cache)
2146
- for key in budget_cfg["projects"]
2147
- ]
2148
- disambig = c._project_disambiguate_labels(
2149
- [{"key": pk} for pk in pkeys]
2150
- )
2160
+ # Collision-aware labels via the shared primitive (#130). Same-basename
2161
+ # roots (/work/app + /personal/app) get a parent segment ("app (work)");
2162
+ # uniquely-named roots keep their bare display_key.
2163
+ labels = c._project_budget_labels(budget_cfg["projects"])
2151
2164
 
2152
2165
  rows = []
2153
- for idx, (key, target) in enumerate(budget_cfg["projects"].items()):
2166
+ for key, target in budget_cfg["projects"].items():
2154
2167
  inputs = c.BudgetInputs(
2155
2168
  target_usd=float(target),
2156
2169
  spent_usd=float(week.get(key, 0.0)),
@@ -2161,7 +2174,7 @@ def _build_project_budget_rows(conn, budget_cfg, now_utc):
2161
2174
  alert_thresholds=thresholds,
2162
2175
  )
2163
2176
  status = c.compute_budget_status(inputs)
2164
- label = disambig.get(idx, pkeys[idx].display_key)
2177
+ label = labels[key]
2165
2178
  rows.append({
2166
2179
  "project": label,
2167
2180
  "project_key": key,
@@ -2256,19 +2269,12 @@ def _append_project_share_rows(snap, rows, has_projects):
2256
2269
  }))
2257
2270
  for r in rows:
2258
2271
  verdict = r["verdict"].upper()
2259
- # `spent` is the ONLY MoneyCell in the row so
2260
- # `_lib_share._collect_project_costs` (which SUMS every MoneyCell in a
2261
- # ProjectCell row) spend-RANKs the anonymized labels by spend alone
2262
- # matching the `project` share convention. A TextCell here would leave
2263
- # every project at cost=0, falling back to lexical ordering. Budget /
2264
- # consumption / verdict stay in the visible `value` TextCell (NOT a
2265
- # second MoneyCell — that would inflate the rank key to spent+budget).
2266
- # The 2-col Metric/Value table renders only `metric` + `value`, so the
2267
- # extra `spent` cell is invisible in the artifact while the Value
2268
- # column keeps the full "spent / budget (pct) VERDICT" string.
2272
+ # Explicit rank via ProjectCell.rank_cost (#130) spend-ranks the
2273
+ # anonymized labels (matching the `project` share convention) without a
2274
+ # hidden MoneyCell. Budget / consumption / verdict stay in the visible
2275
+ # `value` TextCell.
2269
2276
  extra_rows.append(_lib_share.Row(cells={
2270
- "metric": _lib_share.ProjectCell(r["project"]),
2271
- "spent": _lib_share.MoneyCell(r["spent_usd"]),
2277
+ "metric": _lib_share.ProjectCell(r["project"], rank_cost=r["spent_usd"]),
2272
2278
  "value": _lib_share.TextCell(
2273
2279
  f"${r['spent_usd']:,.2f} / ${r['budget_usd']:,.2f} "
2274
2280
  f"({r['consumption_pct']:.0f}%) {verdict}"
@@ -547,6 +547,23 @@ def _reconcile_budget_on_config_write(validated_budget):
547
547
  eprint(f"[budget-milestone] reconcile on set failed: {exc}")
548
548
 
549
549
 
550
+ def _project_crossings(items, thresholds, by_proj):
551
+ """Yield ``(project_key, threshold, spent, target, consumption_pct)`` for
552
+ every crossed (project, threshold) pair (#130). Shared by the firing path
553
+ (record-usage) and the reconcile path (config write) so they differ ONLY in
554
+ the dispatch tail. Pure arithmetic — no DB, no I/O. Applies the +1e-9
555
+ float-floor snap (CLAUDE.md gotcha) and yields thresholds in sorted order.
556
+ ``items`` is an iterable of ``(project_key, target)`` pairs."""
557
+ sorted_thresholds = sorted(thresholds)
558
+ for project_key, target in items:
559
+ spent = float(by_proj.get(project_key, 0.0))
560
+ target = float(target)
561
+ consumption_pct = (spent / target * 100.0) if target > 0 else 0.0
562
+ for t in sorted_thresholds:
563
+ if consumption_pct + 1e-9 >= t:
564
+ yield (project_key, t, spent, target, consumption_pct)
565
+
566
+
550
567
  def _reconcile_project_budget_milestones_on_write(
551
568
  validated_budget, touched_projects=None
552
569
  ):
@@ -614,30 +631,29 @@ def _reconcile_project_budget_milestones_on_write(
614
631
  if k in touched_projects
615
632
  ]
616
633
  )
617
- for project_key, target in items:
618
- spent = float(by_proj.get(project_key, 0.0))
619
- target = float(target)
620
- consumption_pct = (
621
- (spent / target * 100.0) if target > 0 else 0.0
634
+ # Same crossing arithmetic as firing, via the shared generator
635
+ # (#130). Reconcile differs ONLY in the tail: UPDATE alerted_at with
636
+ # NO dispatch (retroactive-storm suppression). `items` already
637
+ # honors touched_projects filtering above.
638
+ for project_key, t, spent, target, consumption_pct in c._project_crossings(
639
+ items, thresholds, by_proj
640
+ ):
641
+ insert_project_budget_milestone(
642
+ conn,
643
+ week_start_at=week_key,
644
+ project_key=project_key,
645
+ threshold=t,
646
+ budget_usd=target,
647
+ spent_usd=spent,
648
+ consumption_pct=consumption_pct,
649
+ commit=False,
650
+ )
651
+ conn.execute(
652
+ "UPDATE project_budget_milestones SET alerted_at = ? "
653
+ "WHERE week_start_at = ? AND project_key = ? "
654
+ " AND threshold = ? AND alerted_at IS NULL",
655
+ (now_utc_iso(), week_key, project_key, t),
622
656
  )
623
- for t in sorted(thresholds):
624
- if consumption_pct + 1e-9 >= t:
625
- insert_project_budget_milestone(
626
- conn,
627
- week_start_at=week_key,
628
- project_key=project_key,
629
- threshold=t,
630
- budget_usd=target,
631
- spent_usd=spent,
632
- consumption_pct=consumption_pct,
633
- commit=False,
634
- )
635
- conn.execute(
636
- "UPDATE project_budget_milestones SET alerted_at = ? "
637
- "WHERE week_start_at = ? AND project_key = ? "
638
- " AND threshold = ? AND alerted_at IS NULL",
639
- (now_utc_iso(), week_key, project_key, t),
640
- )
641
657
  conn.commit()
642
658
  finally:
643
659
  conn.close()
@@ -132,6 +132,27 @@ def _sum_cost_by_project(
132
132
  return out
133
133
 
134
134
 
135
+ def _project_budget_labels(keys):
136
+ """Collision-aware ``{project_key: label}`` for a set of budget project
137
+ keys. Single source of the resolve+disambiguate primitive used by the
138
+ budget table (`_build_project_budget_rows`), the alert payload
139
+ (`maybe_record_project_budget_milestone`), and the dashboard SSE envelope
140
+ (`_envelope_rows_project_budget`) — issue #130. Each caller passes its own
141
+ key feed; the label for a key is identical across callers only when they
142
+ feed the same key set (the dashboard intentionally feeds its alerted-row
143
+ subset). Output is order-independent (disambiguation keys off basename
144
+ collisions, not position)."""
145
+ c = _cctally()
146
+ keys = list(keys)
147
+ resolver_cache: dict = {}
148
+ pkeys = [c._resolve_project_key(k, "git-root", resolver_cache) for k in keys]
149
+ disambig = c._project_disambiguate_labels([{"key": pk} for pk in pkeys])
150
+ return {
151
+ keys[i]: disambig.get(i, pkeys[i].display_key)
152
+ for i in range(len(keys))
153
+ }
154
+
155
+
135
156
  def _accumulate_entry_into_bucket(
136
157
  b: dict,
137
158
  entry: "_JoinedClaudeEntry",
@@ -277,12 +277,12 @@ def insert_project_budget_milestone(*args, **kwargs):
277
277
  return sys.modules["cctally"].insert_project_budget_milestone(*args, **kwargs)
278
278
 
279
279
 
280
- def _resolve_project_key(*args, **kwargs):
281
- return sys.modules["cctally"]._resolve_project_key(*args, **kwargs)
280
+ def _project_budget_labels(*args, **kwargs):
281
+ return sys.modules["cctally"]._project_budget_labels(*args, **kwargs)
282
282
 
283
283
 
284
- def _project_disambiguate_labels(*args, **kwargs):
285
- return sys.modules["cctally"]._project_disambiguate_labels(*args, **kwargs)
284
+ def _project_crossings(*args, **kwargs):
285
+ return sys.modules["cctally"]._project_crossings(*args, **kwargs)
286
286
 
287
287
 
288
288
  def _get_budget_config(*args, **kwargs):
@@ -912,28 +912,17 @@ def maybe_record_project_budget_milestone(saved: dict[str, Any]) -> None:
912
912
  if not pending:
913
913
  return # nothing left to cross this week → skip the cost scan
914
914
 
915
- # Resolve every configured key to its ProjectKey ONCE, then route the
916
- # firing-path labels through the SAME collision-disambiguation primitive
917
- # the display uses (`_build_project_budget_rows` `cmd_project`'s table
918
- # / share snapshot). A bare `display_key` is just the basename, so a
919
- # uniquely-named project keeps its bare basename in the notification —
920
- # byte-matching the table + dashboard chip and only same-basename
921
- # roots (`/work/app` + `/personal/app`) get the `(parent)` segment
922
- # ("app (work)" / "app (personal)"). The configured set is small + the
923
- # resolver caches, so this is near-free on the rare crossing tick.
924
- resolver_cache: dict = {}
925
- proj_keys = sorted(projects)
926
- pkeys = [
927
- _resolve_project_key(p, "git-root", resolver_cache)
928
- for p in proj_keys
929
- ]
930
- disambig = _project_disambiguate_labels(
931
- [{"key": pk} for pk in pkeys]
932
- )
933
- label_by_key = {
934
- proj_keys[i]: disambig.get(i, pkeys[i].display_key)
935
- for i in range(len(proj_keys))
936
- }
915
+ # Collision-aware labels via the shared primitive (#130) byte-matching
916
+ # the display table + dashboard chip for the same key feed. A
917
+ # uniquely-named project keeps its bare basename in the notification;
918
+ # only same-basename roots (`/work/app` + `/personal/app`) get the
919
+ # `(parent)` segment ("app (work)" / "app (personal)"). Resolved LAZILY
920
+ # (just-in-time on the first genuine crossing below): the map does
921
+ # per-key git-root resolution but is consumed ONLY when a new crossing
922
+ # dispatches, and most pending ticks scan without crossing so we skip
923
+ # the resolution entirely on the common no-dispatch tick. Same map,
924
+ # same labels; only the timing moves.
925
+ label_by_key = None
937
926
 
938
927
  # ONE grouped scan over the week's session entries, bucketed by
939
928
  # canonical git-root. skip_sync=False (self-sufficient): the global
@@ -945,55 +934,57 @@ def maybe_record_project_budget_milestone(saved: dict[str, Any]) -> None:
945
934
  by_proj = _sum_cost_by_project(
946
935
  week_start_at, now_utc, mode="auto", skip_sync=False
947
936
  )
948
- for project_key, t in pending:
949
- spent = float(by_proj.get(project_key, 0.0))
950
- target = float(projects[project_key])
951
- # +1e-9 snap-up: spent/target*100 can land one ULP below an integer
952
- # threshold (CLAUDE.md float-floor gotcha).
953
- consumption_pct = (spent / target * 100.0) if target > 0 else 0.0
954
- if consumption_pct + 1e-9 >= t:
955
- inserted = insert_project_budget_milestone(
956
- conn,
937
+ # Crossing arithmetic via the shared generator (#130). Feed ALL
938
+ # configured (project, threshold) pairs; dispatch is gated SOLELY by
939
+ # INSERT-OR-IGNORE rowcount==1 (genuine new crossing). The pending
940
+ # pre-probe above stays as the scan-skip optimization, NOT a write gate
941
+ # already-recorded pairs get rowcount==0 here and silently skip
942
+ # ([Dedup mustn't gate side effects]).
943
+ for project_key, t, spent, target, consumption_pct in _project_crossings(
944
+ projects.items(), sorted_thresholds, by_proj
945
+ ):
946
+ inserted = insert_project_budget_milestone(
947
+ conn,
948
+ week_start_at=week_key,
949
+ project_key=project_key,
950
+ threshold=t,
951
+ budget_usd=target,
952
+ spent_usd=spent,
953
+ consumption_pct=consumption_pct,
954
+ commit=False,
955
+ )
956
+ # Only the genuine-new-crossing winner (rowcount==1) dispatches; a
957
+ # racing record-usage instance OR an already-recorded pair gets
958
+ # rowcount==0 and skips.
959
+ if inserted == 1:
960
+ crossed_at = now_utc_iso()
961
+ # set-then-dispatch: alerted_at lands on the row BEFORE the
962
+ # Popen, sharing this transaction with the INSERT (commit=False).
963
+ # `alerted_at IS NULL` is write-once defense-in-depth.
964
+ conn.execute(
965
+ "UPDATE project_budget_milestones SET alerted_at = ? "
966
+ "WHERE week_start_at = ? AND project_key = ? "
967
+ " AND threshold = ? AND alerted_at IS NULL",
968
+ (crossed_at, week_key, project_key, t),
969
+ )
970
+ # Collision-aware label (shared primitive, #130); resolved once
971
+ # on the first dispatch and reused for the rest of this tick.
972
+ # Kept defensive fallback (F4).
973
+ if label_by_key is None:
974
+ label_by_key = _project_budget_labels(sorted(projects))
975
+ project_label = label_by_key.get(
976
+ project_key, os.path.basename(project_key) or project_key
977
+ )
978
+ pending_alerts.append(_build_alert_payload_project_budget(
979
+ threshold=t,
980
+ crossed_at_utc=crossed_at,
957
981
  week_start_at=week_key,
982
+ project=project_label,
958
983
  project_key=project_key,
959
- threshold=t,
960
984
  budget_usd=target,
961
985
  spent_usd=spent,
962
986
  consumption_pct=consumption_pct,
963
- commit=False,
964
- )
965
- # Only the genuine-new-crossing winner (rowcount==1) dispatches;
966
- # a racing record-usage instance gets rowcount==0 and skips.
967
- if inserted == 1:
968
- crossed_at = now_utc_iso()
969
- # set-then-dispatch: alerted_at lands on the row BEFORE the
970
- # Popen, sharing this transaction with the INSERT
971
- # (commit=False). `alerted_at IS NULL` is write-once
972
- # defense-in-depth.
973
- conn.execute(
974
- "UPDATE project_budget_milestones SET alerted_at = ? "
975
- "WHERE week_start_at = ? AND project_key = ? "
976
- " AND threshold = ? AND alerted_at IS NULL",
977
- (crossed_at, week_key, project_key, t),
978
- )
979
- # Label is collision-aware, byte-matching the display: a
980
- # uniquely-named project notifies as its bare basename,
981
- # only same-basename roots get the `(parent)` segment (the
982
- # `label_by_key` map built above via the shared
983
- # `_project_disambiguate_labels` primitive, spec §5.3).
984
- project_label = label_by_key.get(
985
- project_key, os.path.basename(project_key) or project_key
986
- )
987
- pending_alerts.append(_build_alert_payload_project_budget(
988
- threshold=t,
989
- crossed_at_utc=crossed_at,
990
- week_start_at=week_key,
991
- project=project_label,
992
- project_key=project_key,
993
- budget_usd=target,
994
- spent_usd=spent,
995
- consumption_pct=consumption_pct,
996
- ))
987
+ ))
997
988
  # Single commit: every INSERT + its alerted_at marker durable together.
998
989
  conn.commit()
999
990
  except Exception as exc:
package/bin/_lib_share.py CHANGED
@@ -66,8 +66,14 @@ class DeltaCell:
66
66
 
67
67
  @dataclass(frozen=True)
68
68
  class ProjectCell:
69
- """Anonymization chokepoint — scrubber rewrites the `label` field."""
69
+ """Anonymization chokepoint — scrubber rewrites the `label` field.
70
+
71
+ `rank_cost` (#130): an explicit spend value used by
72
+ `_collect_project_costs` to rank anonymized labels, replacing the old
73
+ hidden-MoneyCell hack. When None, ranking falls back to summing sibling
74
+ MoneyCells (back-compat for every non-budget construction site)."""
70
75
  label: str
76
+ rank_cost: float | None = None
71
77
 
72
78
  Cell = TextCell | MoneyCell | PercentCell | DateCell | DeltaCell | ProjectCell
73
79
 
@@ -735,7 +741,8 @@ def _render_svg_footer(snap: ShareSnapshot, *, palette: dict,
735
741
 
736
742
  def _collect_project_costs(snap: ShareSnapshot) -> dict[str, float]:
737
743
  """Walk rows: for each row containing a ProjectCell, sum MoneyCell values
738
- in the same row under the project label.
744
+ in the same row under the project label — unless the ProjectCell carries an
745
+ explicit ``rank_cost``, which takes precedence over the MoneyCell sum (#130).
739
746
 
740
747
  Charts also contribute via ChartPoint.project_label + y_value (when y_value
741
748
  is in $). For consistency we union both sources; rows take precedence on
@@ -744,13 +751,16 @@ def _collect_project_costs(snap: ShareSnapshot) -> dict[str, float]:
744
751
  for row in snap.rows:
745
752
  proj_label: str | None = None
746
753
  money = 0.0
754
+ explicit_rank: float | None = None
747
755
  for cell in row.cells.values():
748
756
  if isinstance(cell, ProjectCell):
749
757
  proj_label = cell.label
758
+ explicit_rank = cell.rank_cost
750
759
  elif isinstance(cell, MoneyCell):
751
760
  money += cell.usd
752
761
  if proj_label is not None:
753
- costs[proj_label] = costs.get(proj_label, 0.0) + money
762
+ contribution = explicit_rank if explicit_rank is not None else money
763
+ costs[proj_label] = costs.get(proj_label, 0.0) + contribution
754
764
 
755
765
  if snap.chart is not None:
756
766
  chart_pts: list[ChartPoint] = []
@@ -825,7 +835,9 @@ def _apply_anon_mapping(
825
835
  new_cells: dict[str, Cell] = {}
826
836
  for key, cell in row.cells.items():
827
837
  if isinstance(cell, ProjectCell) and cell.label in mapping:
828
- new_cells[key] = ProjectCell(mapping[cell.label])
838
+ new_cells[key] = ProjectCell(
839
+ mapping[cell.label], rank_cost=cell.rank_cost
840
+ )
829
841
  else:
830
842
  new_cells[key] = cell
831
843
  new_rows.append(Row(cells=new_cells))
package/bin/cctally CHANGED
@@ -634,6 +634,9 @@ cmd_project = _cctally_project.cmd_project
634
634
  # per-project budget display (cmd_budget) and the Task 3 firing path reach it
635
635
  # via the cctally namespace.
636
636
  _sum_cost_by_project = _cctally_project._sum_cost_by_project
637
+ # Shared per-project label disambiguation (#130). Re-exported so the budget
638
+ # display, firing path, and dashboard SSE envelope reach the SAME primitive.
639
+ _project_budget_labels = _cctally_project._project_budget_labels
637
640
 
638
641
  # Eager re-export of bin/_cctally_pricing_check.py — `cmd_pricing_check`
639
642
  # is invoked via the parser's `set_defaults(func=c.cmd_pricing_check)`,
@@ -2081,6 +2084,7 @@ get_milestones_for_week = _cctally_milestones.get_milestones_for_wee
2081
2084
  insert_percent_milestone = _cctally_milestones.insert_percent_milestone # record shim; idempotency-test mod.
2082
2085
  insert_budget_milestone = _cctally_milestones.insert_budget_milestone # record shim
2083
2086
  insert_project_budget_milestone = _cctally_milestones.insert_project_budget_milestone # record shim; project-budget-config-test ns[]
2087
+ _project_crossings = _cctally_milestones._project_crossings # record shim; milestones c. (#130 firing/reconcile shared crossing arithmetic)
2084
2088
  insert_projected_milestone = _cctally_milestones.insert_projected_milestone # record shim
2085
2089
  _projected_levels_already_latched = _cctally_milestones._projected_levels_already_latched # record shim
2086
2090
  _reconcile_budget_milestones_on_set = _cctally_milestones._reconcile_budget_milestones_on_set # test_budget_alerts ns[]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cctally",
3
- "version": "1.25.0",
3
+ "version": "1.26.0",
4
4
  "description": "Claude Code usage tracker and local dashboard for Pro/Max subscription limits - weekly cost-per-percent trend, quota forecasts, threshold alerts. ccusage-compatible.",
5
5
  "homepage": "https://github.com/omrikais/cctally",
6
6
  "repository": {