cctally 1.23.0 → 1.25.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.
@@ -33,7 +33,7 @@ import math
33
33
  import os
34
34
  import sqlite3
35
35
  import sys
36
- from dataclasses import dataclass
36
+ from dataclasses import dataclass, replace
37
37
 
38
38
  from _cctally_core import (
39
39
  _command_as_of, _normalize_week_boundary_dt, compute_week_bounds,
@@ -1578,8 +1578,12 @@ def cmd_budget(args: argparse.Namespace) -> int:
1578
1578
  return 2
1579
1579
 
1580
1580
  if action == "set":
1581
+ if getattr(args, "project", None) is not None:
1582
+ return _cmd_budget_set_project(args)
1581
1583
  return _cmd_budget_set(args)
1582
1584
  if action == "unset":
1585
+ if getattr(args, "project", None) is not None:
1586
+ return _cmd_budget_unset_project(args)
1583
1587
  return _cmd_budget_unset(args)
1584
1588
 
1585
1589
  # ── bare status ──
@@ -1593,11 +1597,42 @@ def cmd_budget(args: argparse.Namespace) -> int:
1593
1597
  except _BudgetConfigError as exc:
1594
1598
  eprint(f"cctally budget: {exc}")
1595
1599
  return 2
1600
+
1601
+ # Per-project section is appended to WHICHEVER global path runs (unset,
1602
+ # no-data, full status) — gated on budget.projects being non-empty. When
1603
+ # empty, NOTHING is appended → the existing global render paths stay
1604
+ # byte-identical (spec §7.3a). It needs the budget window, so the work
1605
+ # happens after we have a conn / now_utc below.
1606
+ has_projects = bool(budget_cfg["projects"])
1607
+
1596
1608
  target = budget_cfg["weekly_usd"]
1597
- if target is None:
1598
- return _budget_render_unset(args) # exit 0, friendly message
1599
1609
 
1610
+ # Resolve the window-dependent per-project rows once (only when configured).
1611
+ # `project_rows` is None when no window resolves (no snapshot yet) — the
1612
+ # render paths degrade to the no-data note for the section. When projects
1613
+ # are unconfigured, we never open a connection just for them: the
1614
+ # individual global paths open their own.
1600
1615
  now_utc = _command_as_of() # honors the CCTALLY_AS_OF testing hook
1616
+ project_rows = None
1617
+ project_window_resolved = False
1618
+ if has_projects:
1619
+ pj_conn = open_db()
1620
+ try:
1621
+ project_rows = _build_project_budget_rows(pj_conn, budget_cfg, now_utc)
1622
+ finally:
1623
+ pj_conn.close()
1624
+ project_window_resolved = project_rows is not None
1625
+
1626
+ if target is None:
1627
+ # Global budget unset → friendly message, then (if configured) the
1628
+ # per-project section. --json carries status:"unset" + projects[].
1629
+ return _budget_render_unset(
1630
+ args,
1631
+ project_rows=project_rows,
1632
+ has_projects=has_projects,
1633
+ project_window_resolved=project_window_resolved,
1634
+ )
1635
+
1601
1636
  conn = open_db()
1602
1637
  inputs = _build_budget_status_inputs(
1603
1638
  conn,
@@ -1609,26 +1644,212 @@ def cmd_budget(args: argparse.Namespace) -> int:
1609
1644
  # No usage snapshot yet → no resolvable week window (spec §6 worst case).
1610
1645
  if getattr(args, "format", None):
1611
1646
  snap = _build_budget_no_data_snapshot(args, budget_cfg, now_utc)
1647
+ snap = _append_project_share_rows(snap, project_rows, has_projects)
1612
1648
  c._share_render_and_emit(snap, args)
1613
1649
  return 0
1614
1650
  if getattr(args, "json", False):
1615
- print(json.dumps({
1651
+ payload = {
1616
1652
  "schemaVersion": _BUDGET_JSON_SCHEMA_VERSION,
1617
1653
  "status": "no_data",
1618
1654
  "weekly_usd": target,
1619
- }))
1655
+ }
1656
+ if has_projects:
1657
+ _append_project_json(payload, project_rows)
1658
+ print(json.dumps(payload))
1620
1659
  return 0
1621
1660
  print(f"Weekly budget: ${target:,.2f} — no usage data yet this week.")
1661
+ _print_project_section_or_note(
1662
+ project_rows, has_projects, project_window_resolved, args
1663
+ )
1622
1664
  return 0
1623
1665
 
1624
1666
  status = c.compute_budget_status(inputs)
1625
1667
  if getattr(args, "format", None):
1626
1668
  snap = _build_budget_snapshot(args, budget_cfg, inputs, status)
1669
+ snap = _append_project_share_rows(snap, project_rows, has_projects)
1627
1670
  c._share_render_and_emit(snap, args)
1628
1671
  return 0
1629
1672
  if getattr(args, "json", False):
1630
- return _budget_emit_json(budget_cfg, inputs, status)
1631
- return _budget_render_terminal(args, budget_cfg, inputs, status)
1673
+ return _budget_emit_json(
1674
+ budget_cfg, inputs, status,
1675
+ project_rows=project_rows, has_projects=has_projects,
1676
+ )
1677
+ rc = _budget_render_terminal(args, budget_cfg, inputs, status)
1678
+ _print_project_section_or_note(
1679
+ project_rows, has_projects, project_window_resolved, args
1680
+ )
1681
+ return rc
1682
+
1683
+
1684
+ def _resolve_project_budget_target(raw: str):
1685
+ """Resolve the ``--project`` value to a canonical git-root path.
1686
+
1687
+ BOTH branches route through ``_resolve_project_key(..., "git-root", {})``
1688
+ so the stored key is the SAME canonical bucket ``_sum_cost_by_project``
1689
+ buckets entries under — otherwise a sub-directory path (e.g.
1690
+ ``~/code/monorepo/packages/foo`` under a monorepo git-root) would store
1691
+ a key that never matches any entry, permanently rendering ``$0``.
1692
+
1693
+ ``__CWD__`` (the bare-flag sentinel) → resolve ``os.getcwd()`` to its
1694
+ ``.git`` root; a result that is ``is_no_git``/``is_unknown`` (not inside
1695
+ a git repo) → return ``None`` (the caller emits the "not inside a git
1696
+ repository" error + exit 2).
1697
+
1698
+ An explicit path → resolve the same way: take ``.git_root`` when a ``.git``
1699
+ is found (so a sub-dir path collapses onto its monorepo root), else the
1700
+ normalized ``bucket_path`` (a path that is itself a git-root resolves to
1701
+ itself; a genuinely non-git path keeps its normalized form). Explicit
1702
+ paths never return ``None`` — they always resolve to a usable key.
1703
+ """
1704
+ c = _cctally()
1705
+ if raw == "__CWD__":
1706
+ key = c._resolve_project_key(os.getcwd(), "git-root", {})
1707
+ if key.is_no_git or key.is_unknown or not key.git_root:
1708
+ return None
1709
+ return key.git_root
1710
+ key = c._resolve_project_key(raw, "git-root", {})
1711
+ return key.git_root or key.bucket_path
1712
+
1713
+
1714
+ def _cmd_budget_set_project(args: argparse.Namespace) -> int:
1715
+ """`cctally budget set AMOUNT --project[=PATH]` — write one entry into
1716
+ `budget.projects`, keyed by the resolved canonical git-root. Writes the
1717
+ DEFAULT config (F4); Task 3's forward-only reconcile runs after the write."""
1718
+ c = _cctally()
1719
+ raw_amount = getattr(args, "amount", None)
1720
+ if raw_amount is None:
1721
+ eprint(
1722
+ "cctally budget: `set --project` requires an amount, e.g. "
1723
+ "cctally budget set 25 --project"
1724
+ )
1725
+ return 2
1726
+ try:
1727
+ amount = float(raw_amount)
1728
+ except (TypeError, ValueError):
1729
+ eprint(f"cctally budget: amount must be a positive number, got {raw_amount!r}")
1730
+ return 2
1731
+ if not math.isfinite(amount) or amount <= 0:
1732
+ eprint(
1733
+ f"cctally budget: amount must be a positive finite number, "
1734
+ f"got {raw_amount!r}"
1735
+ )
1736
+ return 2
1737
+
1738
+ root = _resolve_project_budget_target(args.project)
1739
+ if root is None:
1740
+ eprint("cctally budget: not inside a git repository")
1741
+ return 2
1742
+
1743
+ with c.config_writer_lock():
1744
+ config = c._load_config_unlocked()
1745
+ existing = config.get("budget")
1746
+ if existing is not None and not isinstance(existing, dict):
1747
+ eprint("cctally budget: budget config must be an object")
1748
+ return 2
1749
+ block = dict(existing or {})
1750
+ # Guard the merge-copy: a hand-edited non-dict `budget.projects`
1751
+ # (string / non-pair list) would traceback on `dict(...)` BEFORE
1752
+ # `_get_budget_config` can raise a controlled _BudgetConfigError.
1753
+ # Mirror the `existing` budget-block guard above → clean exit 2.
1754
+ existing_projects = block.get("projects")
1755
+ if existing_projects is not None and not isinstance(
1756
+ existing_projects, dict
1757
+ ):
1758
+ eprint("cctally budget: budget.projects must be an object")
1759
+ return 2
1760
+ projects = dict(existing_projects or {})
1761
+ projects[root] = amount
1762
+ block["projects"] = projects
1763
+ config["budget"] = block
1764
+ try:
1765
+ validated = _get_budget_config(config)
1766
+ except _BudgetConfigError as exc:
1767
+ eprint(f"cctally budget: {exc}")
1768
+ return 2
1769
+ block["projects"] = dict(validated["projects"])
1770
+ config["budget"] = block
1771
+ c.save_config(config)
1772
+
1773
+ # Forward-only reconcile (spec §6.8): record `root`'s already-crossed
1774
+ # (project, threshold) pairs alerted_at-set WITHOUT dispatch, so setting a
1775
+ # budget mid-week (already over) doesn't storm. Scoped to the TOUCHED
1776
+ # project so it never latches a sibling's already-crossed-but-not-yet-
1777
+ # dispatched threshold (which would permanently suppress that alert).
1778
+ c._reconcile_project_budget_milestones_on_write(
1779
+ validated, touched_projects={root}
1780
+ )
1781
+
1782
+ basename = os.path.basename(root) or root
1783
+ if getattr(args, "json", False):
1784
+ print(json.dumps({
1785
+ "status": "set",
1786
+ "project_key": root,
1787
+ "budget_usd": amount,
1788
+ }))
1789
+ return 0
1790
+ print(f"Project budget set: {basename} ${amount:,.2f}")
1791
+ return 0
1792
+
1793
+
1794
+ def _cmd_budget_unset_project(args: argparse.Namespace) -> int:
1795
+ """`cctally budget unset --project[=PATH]` — remove one `budget.projects`
1796
+ entry. Idempotent (message-only when absent)."""
1797
+ c = _cctally()
1798
+ root = _resolve_project_budget_target(args.project)
1799
+ if root is None:
1800
+ eprint("cctally budget: not inside a git repository")
1801
+ return 2
1802
+
1803
+ removed = False
1804
+ with c.config_writer_lock():
1805
+ config = c._load_config_unlocked()
1806
+ existing = config.get("budget")
1807
+ if existing is not None and not isinstance(existing, dict):
1808
+ eprint("cctally budget: budget config must be an object")
1809
+ return 2
1810
+ block = dict(existing or {})
1811
+ # Guard the merge-copy: a hand-edited non-dict `budget.projects`
1812
+ # would traceback on `dict(...)` before the validator can report a
1813
+ # controlled error (mirrors `_cmd_budget_set_project`).
1814
+ existing_projects = block.get("projects")
1815
+ if existing_projects is not None and not isinstance(
1816
+ existing_projects, dict
1817
+ ):
1818
+ eprint("cctally budget: budget.projects must be an object")
1819
+ return 2
1820
+ projects = dict(existing_projects or {})
1821
+ if root in projects:
1822
+ projects.pop(root)
1823
+ removed = True
1824
+ block["projects"] = projects
1825
+ config["budget"] = block
1826
+ try:
1827
+ validated = _get_budget_config(config)
1828
+ except _BudgetConfigError as exc:
1829
+ eprint(f"cctally budget: {exc}")
1830
+ return 2
1831
+ c.save_config(config)
1832
+
1833
+ # Reconcile scoped to the TOUCHED project. The unset removed `root` from
1834
+ # the map, so this is a no-op for `root` — and it must NOT scan the
1835
+ # remaining projects: scanning them would latch a sibling's already-crossed-
1836
+ # but-not-yet-dispatched threshold, permanently suppressing its real alert.
1837
+ c._reconcile_project_budget_milestones_on_write(
1838
+ validated, touched_projects={root}
1839
+ )
1840
+
1841
+ basename = os.path.basename(root) or root
1842
+ if getattr(args, "json", False):
1843
+ print(json.dumps({
1844
+ "status": "unset" if removed else "noop",
1845
+ "project_key": root,
1846
+ }))
1847
+ return 0
1848
+ if removed:
1849
+ print(f"Project budget cleared: {basename}")
1850
+ else:
1851
+ print(f"No project budget set for: {basename}")
1852
+ return 0
1632
1853
 
1633
1854
 
1634
1855
  def _cmd_budget_set(args: argparse.Namespace) -> int:
@@ -1722,21 +1943,41 @@ def _cmd_budget_unset(args: argparse.Namespace) -> int:
1722
1943
  return 0
1723
1944
 
1724
1945
 
1725
- def _budget_render_unset(args: argparse.Namespace) -> int:
1726
- """No budget set → friendly stdout message, exit 0 (NOT an error)."""
1946
+ def _budget_render_unset(
1947
+ args: argparse.Namespace,
1948
+ *,
1949
+ project_rows=None,
1950
+ has_projects: bool = False,
1951
+ project_window_resolved: bool = False,
1952
+ ) -> int:
1953
+ """No GLOBAL budget set → friendly stdout message, exit 0 (NOT an error).
1954
+
1955
+ When per-project budgets ARE configured (``has_projects``), the
1956
+ per-project section is STILL rendered below the unset message (spec
1957
+ §7.3a) — a project-only configuration is fully supported. When
1958
+ ``has_projects`` is False, every output mode is byte-identical to the
1959
+ pre-feature behavior (no ``projects`` key in --json, no extra lines).
1960
+ """
1727
1961
  c = _cctally()
1728
1962
  if getattr(args, "format", None):
1729
1963
  snap = _build_budget_no_budget_snapshot(args)
1964
+ snap = _append_project_share_rows(snap, project_rows, has_projects)
1730
1965
  c._share_render_and_emit(snap, args)
1731
1966
  return 0
1732
1967
  if getattr(args, "json", False):
1733
- print(json.dumps({
1968
+ payload = {
1734
1969
  "schemaVersion": _BUDGET_JSON_SCHEMA_VERSION,
1735
1970
  "status": "unset",
1736
1971
  "weekly_usd": None,
1737
- }))
1972
+ }
1973
+ if has_projects:
1974
+ _append_project_json(payload, project_rows)
1975
+ print(json.dumps(payload))
1738
1976
  return 0
1739
1977
  print("No weekly budget set. Set one with: cctally budget set <amount>.")
1978
+ _print_project_section_or_note(
1979
+ project_rows, has_projects, project_window_resolved, args
1980
+ )
1740
1981
  return 0
1741
1982
 
1742
1983
 
@@ -1821,9 +2062,16 @@ def _budget_alerts_line(budget_cfg, status) -> str:
1821
2062
  return f" Alerts: on · thresholds {thr_str} · {tail}"
1822
2063
 
1823
2064
 
1824
- def _budget_emit_json(budget_cfg, inputs, status) -> int:
2065
+ def _budget_emit_json(
2066
+ budget_cfg, inputs, status, *, project_rows=None, has_projects=False
2067
+ ) -> int:
1825
2068
  """Emit the full BudgetStatus + config echo + window as JSON (schemaVersion 1).
1826
- Window timestamps are `…Z`, ignoring display.tz (every --json is UTC)."""
2069
+ Window timestamps are `…Z`, ignoring display.tz (every --json is UTC).
2070
+
2071
+ When per-project budgets are configured (``has_projects``), an additive
2072
+ ``projects: [...]`` array is appended — ADDITIVE only, NO schemaVersion
2073
+ bump (spec §7.4). Absent when projects are empty so the global --json stays
2074
+ byte-identical."""
1827
2075
  payload = {
1828
2076
  "schemaVersion": _BUDGET_JSON_SCHEMA_VERSION,
1829
2077
  "status": "ok",
@@ -1846,16 +2094,232 @@ def _budget_emit_json(budget_cfg, inputs, status) -> int:
1846
2094
  "low_confidence": status.low_confidence,
1847
2095
  "crossed_thresholds": list(status.crossed_thresholds),
1848
2096
  }
2097
+ if has_projects:
2098
+ _append_project_json(payload, project_rows)
1849
2099
  print(json.dumps(payload))
1850
2100
  return 0
1851
2101
 
1852
2102
 
2103
+ def _build_project_budget_rows(conn, budget_cfg, now_utc):
2104
+ """Build per-project budget status dicts for the configured projects.
2105
+
2106
+ Returns ``None`` when no budget week window resolves (no usage snapshot
2107
+ yet) — the caller renders the "no usage data yet this week" note instead
2108
+ of a table. Otherwise a list of dicts (one per configured project),
2109
+ SORTED by ``consumption_pct`` descending, each carrying the same verdict
2110
+ fields as the global status (from one ``compute_budget_status`` codepath).
2111
+
2112
+ Spend is the shared ``_sum_cost_by_project`` scan over the current week
2113
+ ``[week_start_at, now]``; the recent-rate input is a SECOND scan over the
2114
+ CLAMPED trailing-24h window ``[max(week_start_at, now-24h), now]``. The
2115
+ clamp at ``week_start_at`` is MANDATORY (spec §7.1): without it a fresh
2116
+ week (``now < week_start + 24h``) pulls pre-reset spend into
2117
+ ``rate_recent`` and false-WARN/OVERs a project. Mirrors the global
2118
+ ``_build_budget_status_inputs`` (forecast.py) exactly.
2119
+
2120
+ A configured ``project_key`` with no matching entry this week (deleted /
2121
+ moved / never-matched repo, spec §7.2) is absent from both scan maps →
2122
+ ``spent=0`` / ``recent_24h=0`` → a ``$0 / 0% / ok`` row, never an error.
2123
+ """
2124
+ c = _cctally()
2125
+ window = _resolve_current_budget_window(conn, now_utc)
2126
+ if window is None:
2127
+ return None
2128
+ week_start_at, week_end_at = window
2129
+ week = c._sum_cost_by_project(week_start_at, now_utc, mode="auto")
2130
+ recent_start = max(week_start_at, now_utc - dt.timedelta(hours=24))
2131
+ last24h = c._sum_cost_by_project(recent_start, now_utc, mode="auto")
2132
+ thresholds = tuple(budget_cfg["alert_thresholds"])
2133
+
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
+ )
2151
+
2152
+ rows = []
2153
+ for idx, (key, target) in enumerate(budget_cfg["projects"].items()):
2154
+ inputs = c.BudgetInputs(
2155
+ target_usd=float(target),
2156
+ spent_usd=float(week.get(key, 0.0)),
2157
+ recent_24h_usd=float(last24h.get(key, 0.0)),
2158
+ week_start_at=week_start_at,
2159
+ week_end_at=week_end_at,
2160
+ now=now_utc,
2161
+ alert_thresholds=thresholds,
2162
+ )
2163
+ status = c.compute_budget_status(inputs)
2164
+ label = disambig.get(idx, pkeys[idx].display_key)
2165
+ rows.append({
2166
+ "project": label,
2167
+ "project_key": key,
2168
+ "budget_usd": float(target),
2169
+ "spent_usd": status.spent_usd,
2170
+ "consumption_pct": status.consumption_pct,
2171
+ "verdict": status.verdict,
2172
+ "low_confidence": status.low_confidence,
2173
+ })
2174
+ rows.sort(key=lambda r: r["consumption_pct"], reverse=True)
2175
+ return rows
2176
+
2177
+
2178
+ def _render_project_budget_section(rows, *, color: bool) -> str:
2179
+ """Render the per-project budget table as plain aligned text below the
2180
+ global status block (spec §7.3). Columns:
2181
+ ``Project · Budget · Spent · Used % · Verdict`` (LOW CONF cue), already
2182
+ sorted by Used % desc by ``_build_project_budget_rows``."""
2183
+ c = _cctally()
2184
+ headers = ["Project", "Budget", "Spent", "Used %", "Verdict"]
2185
+ body = []
2186
+ for r in rows:
2187
+ verdict_label = {"ok": "OK", "warn": "WARN", "over": "OVER"}.get(
2188
+ r["verdict"], r["verdict"].upper()
2189
+ )
2190
+ if r["low_confidence"]:
2191
+ verdict_label += " (LOW CONF)"
2192
+ body.append([
2193
+ r["project"],
2194
+ f"${r['budget_usd']:,.2f}",
2195
+ f"${r['spent_usd']:,.2f}",
2196
+ f"{r['consumption_pct']:.1f}%",
2197
+ verdict_label,
2198
+ ])
2199
+ # Column widths sized to content (header + every cell).
2200
+ widths = [len(h) for h in headers]
2201
+ for cells in body:
2202
+ for i, cell in enumerate(cells):
2203
+ widths[i] = max(widths[i], len(cell))
2204
+ # Project + Verdict left-aligned; the three money/percent columns right.
2205
+ aligns = ["<", ">", ">", ">", "<"]
2206
+
2207
+ def _fmt_row(cells):
2208
+ return " ".join(
2209
+ f"{cell:{aligns[i]}{widths[i]}}" for i, cell in enumerate(cells)
2210
+ )
2211
+
2212
+ lines = ["", "Per-project budgets:", "", (" " + _fmt_row(headers)).rstrip()]
2213
+ for cells, r in zip(body, rows):
2214
+ rendered = (" " + _fmt_row(cells)).rstrip()
2215
+ if color:
2216
+ code = _budget_verdict_ansi_code(r["verdict"])
2217
+ rendered = c._style_ansi(rendered, code, color)
2218
+ lines.append(rendered)
2219
+ return "\n".join(lines)
2220
+
2221
+
2222
+ def _print_project_section_or_note(rows, has_projects, window_resolved, args):
2223
+ """Terminal helper: when projects are configured, print either the
2224
+ per-project table (window resolved) or a brief no-data note (parallel to
2225
+ the global no-data text, spec §7.3a). No-op when projects are empty so the
2226
+ existing global terminal output stays byte-identical."""
2227
+ if not has_projects:
2228
+ return
2229
+ c = _cctally()
2230
+ if not window_resolved:
2231
+ print("\nPer-project budgets: no usage data yet this week.")
2232
+ return
2233
+ print(_render_project_budget_section(rows, color=c._supports_color_stdout()))
2234
+
2235
+
2236
+ def _append_project_share_rows(snap, rows, has_projects):
2237
+ """Append per-project ProjectCell rows to a budget ShareSnapshot so the
2238
+ share-output anonymization chokepoint (``_lib_share._scrub``) rewrites the
2239
+ basenames under default output and reveals them under ``--reveal-projects``
2240
+ (spec §7.5). No-op when projects are empty → existing share goldens stay
2241
+ byte-identical. Project names go through ``ProjectCell`` (the single
2242
+ anonymization chokepoint); the [Anonymization fails closed] invariant
2243
+ applies."""
2244
+ if not has_projects or not rows:
2245
+ return snap
2246
+ c = _cctally()
2247
+ _lib_share = c._share_load_lib()
2248
+ # Reuse the snapshot's 2-col (Metric/Value) shape but render each project
2249
+ # as a ProjectCell in the metric column + its spend in the value.
2250
+ extra_rows = []
2251
+ # A header-ish separator row keeps the per-project block visually distinct
2252
+ # without changing the column schema.
2253
+ extra_rows.append(_lib_share.Row(cells={
2254
+ "metric": _lib_share.TextCell("— Per-project budgets —"),
2255
+ "value": _lib_share.TextCell(""),
2256
+ }))
2257
+ for r in rows:
2258
+ 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.
2269
+ extra_rows.append(_lib_share.Row(cells={
2270
+ "metric": _lib_share.ProjectCell(r["project"]),
2271
+ "spent": _lib_share.MoneyCell(r["spent_usd"]),
2272
+ "value": _lib_share.TextCell(
2273
+ f"${r['spent_usd']:,.2f} / ${r['budget_usd']:,.2f} "
2274
+ f"({r['consumption_pct']:.0f}%) {verdict}"
2275
+ ),
2276
+ }))
2277
+ return _replace_snapshot_rows(snap, tuple(snap.rows) + tuple(extra_rows))
2278
+
2279
+
2280
+ def _replace_snapshot_rows(snap, rows):
2281
+ """Return a copy of ``snap`` with ``rows`` replaced (frozen dataclass)."""
2282
+ return replace(snap, rows=rows)
2283
+
2284
+
2285
+ def _append_project_json(payload: dict, project_rows) -> None:
2286
+ """Attach the additive ``projects[]`` array to a budget ``--json`` payload
2287
+ (spec §7.4). Always present when this helper is called (the caller has
2288
+ already gated on ``has_projects``); empty ``project_rows`` → ``[]`` so a
2289
+ project-only configuration with no resolvable rows still emits the key.
2290
+ Single chokepoint for the three ``--json`` paths (full status / no-data /
2291
+ unset) so the append shape stays identical across them."""
2292
+ payload["projects"] = (
2293
+ _project_rows_json(project_rows) if project_rows else []
2294
+ )
2295
+
2296
+
2297
+ def _project_rows_json(rows) -> list:
2298
+ """Project the per-project status dicts onto the additive ``projects[]``
2299
+ JSON shape (spec §7.4): real paths, no anonymization (every --json emits
2300
+ real values, like ``project --json``)."""
2301
+ return [
2302
+ {
2303
+ "project": r["project"],
2304
+ "project_key": r["project_key"],
2305
+ "budget_usd": r["budget_usd"],
2306
+ "spent_usd": r["spent_usd"],
2307
+ "consumption_pct": r["consumption_pct"],
2308
+ "verdict": r["verdict"],
2309
+ "low_confidence": r["low_confidence"],
2310
+ }
2311
+ for r in rows
2312
+ ]
2313
+
2314
+
1853
2315
  def _build_budget_snapshot(args, budget_cfg, inputs, status):
1854
2316
  """Build a `_lib_share.ShareSnapshot` (cmd="budget") for `--format` output.
1855
2317
 
1856
- `--reveal-projects` is inert for budget there are no ProjectCells, so
1857
- `_scrub` returns the snapshot unchanged. No parallel renderer; the gate
1858
- calls `_share_render_and_emit(snap, args)`."""
2318
+ This builds the GLOBAL budget rows only; when per-project budgets are
2319
+ configured, `_append_project_share_rows` appends ProjectCell rows so
2320
+ `--reveal-projects` reveals (or `_scrub` anonymizes) the per-project
2321
+ basenames via the share chokepoint. No parallel renderer; the gate calls
2322
+ `_share_render_and_emit(snap, args)`."""
1859
2323
  c = _cctally()
1860
2324
  _lib_share = c._share_load_lib()
1861
2325
  tz_label = c._share_display_tz_label(getattr(args, "_resolved_tz", None))