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 +8 -0
- package/bin/_cctally_dashboard.py +8 -14
- package/bin/_cctally_forecast.py +41 -35
- package/bin/_cctally_milestones.py +39 -23
- package/bin/_cctally_project.py +21 -0
- package/bin/_cctally_record.py +61 -70
- package/bin/_lib_share.py +16 -4
- package/bin/cctally +4 -0
- package/package.json +1 -1
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
|
-
|
|
4289
|
-
|
|
4290
|
-
#
|
|
4291
|
-
#
|
|
4292
|
-
#
|
|
4293
|
-
|
|
4294
|
-
|
|
4295
|
-
|
|
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"])
|
package/bin/_cctally_forecast.py
CHANGED
|
@@ -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
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
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
|
-
#
|
|
2135
|
-
#
|
|
2136
|
-
#
|
|
2137
|
-
|
|
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
|
|
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 =
|
|
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
|
-
#
|
|
2260
|
-
#
|
|
2261
|
-
#
|
|
2262
|
-
#
|
|
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
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
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()
|
package/bin/_cctally_project.py
CHANGED
|
@@ -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",
|
package/bin/_cctally_record.py
CHANGED
|
@@ -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
|
|
281
|
-
return sys.modules["cctally"].
|
|
280
|
+
def _project_budget_labels(*args, **kwargs):
|
|
281
|
+
return sys.modules["cctally"]._project_budget_labels(*args, **kwargs)
|
|
282
282
|
|
|
283
283
|
|
|
284
|
-
def
|
|
285
|
-
return sys.modules["cctally"].
|
|
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
|
-
#
|
|
916
|
-
#
|
|
917
|
-
#
|
|
918
|
-
#
|
|
919
|
-
#
|
|
920
|
-
#
|
|
921
|
-
#
|
|
922
|
-
#
|
|
923
|
-
#
|
|
924
|
-
|
|
925
|
-
|
|
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
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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": {
|