cctally 1.24.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.
@@ -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,238 @@ 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 _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
+
1724
+ def _cmd_budget_set_project(args: argparse.Namespace) -> int:
1725
+ """`cctally budget set AMOUNT --project[=PATH]` — write one entry into
1726
+ `budget.projects`, keyed by the resolved canonical git-root. Writes the
1727
+ DEFAULT config (F4); Task 3's forward-only reconcile runs after the write."""
1728
+ c = _cctally()
1729
+ raw_amount = getattr(args, "amount", None)
1730
+ if raw_amount is None:
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
+ )
1751
+ return 2
1752
+ try:
1753
+ amount = float(raw_amount)
1754
+ except (TypeError, ValueError):
1755
+ eprint(f"cctally budget: amount must be a positive number, got {raw_amount!r}")
1756
+ return 2
1757
+ if not math.isfinite(amount) or amount <= 0:
1758
+ eprint(
1759
+ f"cctally budget: amount must be a positive finite number, "
1760
+ f"got {raw_amount!r}"
1761
+ )
1762
+ return 2
1763
+
1764
+ root = _resolve_project_budget_target(args.project)
1765
+ if root is None:
1766
+ eprint("cctally budget: not inside a git repository")
1767
+ return 2
1768
+
1769
+ with c.config_writer_lock():
1770
+ config = c._load_config_unlocked()
1771
+ existing = config.get("budget")
1772
+ if existing is not None and not isinstance(existing, dict):
1773
+ eprint("cctally budget: budget config must be an object")
1774
+ return 2
1775
+ block = dict(existing or {})
1776
+ # Guard the merge-copy: a hand-edited non-dict `budget.projects`
1777
+ # (string / non-pair list) would traceback on `dict(...)` BEFORE
1778
+ # `_get_budget_config` can raise a controlled _BudgetConfigError.
1779
+ # Mirror the `existing` budget-block guard above → clean exit 2.
1780
+ existing_projects = block.get("projects")
1781
+ if existing_projects is not None and not isinstance(
1782
+ existing_projects, dict
1783
+ ):
1784
+ eprint("cctally budget: budget.projects must be an object")
1785
+ return 2
1786
+ projects = dict(existing_projects or {})
1787
+ projects[root] = amount
1788
+ block["projects"] = projects
1789
+ config["budget"] = block
1790
+ try:
1791
+ validated = _get_budget_config(config)
1792
+ except _BudgetConfigError as exc:
1793
+ eprint(f"cctally budget: {exc}")
1794
+ return 2
1795
+ block["projects"] = dict(validated["projects"])
1796
+ config["budget"] = block
1797
+ c.save_config(config)
1798
+
1799
+ # Forward-only reconcile (spec §6.8): record `root`'s already-crossed
1800
+ # (project, threshold) pairs alerted_at-set WITHOUT dispatch, so setting a
1801
+ # budget mid-week (already over) doesn't storm. Scoped to the TOUCHED
1802
+ # project so it never latches a sibling's already-crossed-but-not-yet-
1803
+ # dispatched threshold (which would permanently suppress that alert).
1804
+ c._reconcile_project_budget_milestones_on_write(
1805
+ validated, touched_projects={root}
1806
+ )
1807
+
1808
+ basename = os.path.basename(root) or root
1809
+ if getattr(args, "json", False):
1810
+ print(json.dumps({
1811
+ "status": "set",
1812
+ "project_key": root,
1813
+ "budget_usd": amount,
1814
+ }))
1815
+ return 0
1816
+ print(f"Project budget set: {basename} ${amount:,.2f}")
1817
+ return 0
1818
+
1819
+
1820
+ def _cmd_budget_unset_project(args: argparse.Namespace) -> int:
1821
+ """`cctally budget unset --project[=PATH]` — remove one `budget.projects`
1822
+ entry. Idempotent (message-only when absent)."""
1823
+ c = _cctally()
1824
+ root = _resolve_project_budget_target(args.project)
1825
+ if root is None:
1826
+ eprint("cctally budget: not inside a git repository")
1827
+ return 2
1828
+
1829
+ removed = False
1830
+ with c.config_writer_lock():
1831
+ config = c._load_config_unlocked()
1832
+ existing = config.get("budget")
1833
+ if existing is not None and not isinstance(existing, dict):
1834
+ eprint("cctally budget: budget config must be an object")
1835
+ return 2
1836
+ block = dict(existing or {})
1837
+ # Guard the merge-copy: a hand-edited non-dict `budget.projects`
1838
+ # would traceback on `dict(...)` before the validator can report a
1839
+ # controlled error (mirrors `_cmd_budget_set_project`).
1840
+ existing_projects = block.get("projects")
1841
+ if existing_projects is not None and not isinstance(
1842
+ existing_projects, dict
1843
+ ):
1844
+ eprint("cctally budget: budget.projects must be an object")
1845
+ return 2
1846
+ projects = dict(existing_projects or {})
1847
+ if root in projects:
1848
+ projects.pop(root)
1849
+ removed = True
1850
+ block["projects"] = projects
1851
+ config["budget"] = block
1852
+ try:
1853
+ validated = _get_budget_config(config)
1854
+ except _BudgetConfigError as exc:
1855
+ eprint(f"cctally budget: {exc}")
1856
+ return 2
1857
+ c.save_config(config)
1858
+
1859
+ # Reconcile scoped to the TOUCHED project. The unset removed `root` from
1860
+ # the map, so this is a no-op for `root` — and it must NOT scan the
1861
+ # remaining projects: scanning them would latch a sibling's already-crossed-
1862
+ # but-not-yet-dispatched threshold, permanently suppressing its real alert.
1863
+ c._reconcile_project_budget_milestones_on_write(
1864
+ validated, touched_projects={root}
1865
+ )
1866
+
1867
+ basename = os.path.basename(root) or root
1868
+ if getattr(args, "json", False):
1869
+ print(json.dumps({
1870
+ "status": "unset" if removed else "noop",
1871
+ "project_key": root,
1872
+ }))
1873
+ return 0
1874
+ if removed:
1875
+ print(f"Project budget cleared: {basename}")
1876
+ else:
1877
+ print(f"No project budget set for: {basename}")
1878
+ return 0
1632
1879
 
1633
1880
 
1634
1881
  def _cmd_budget_set(args: argparse.Namespace) -> int:
@@ -1722,21 +1969,41 @@ def _cmd_budget_unset(args: argparse.Namespace) -> int:
1722
1969
  return 0
1723
1970
 
1724
1971
 
1725
- def _budget_render_unset(args: argparse.Namespace) -> int:
1726
- """No budget set → friendly stdout message, exit 0 (NOT an error)."""
1972
+ def _budget_render_unset(
1973
+ args: argparse.Namespace,
1974
+ *,
1975
+ project_rows=None,
1976
+ has_projects: bool = False,
1977
+ project_window_resolved: bool = False,
1978
+ ) -> int:
1979
+ """No GLOBAL budget set → friendly stdout message, exit 0 (NOT an error).
1980
+
1981
+ When per-project budgets ARE configured (``has_projects``), the
1982
+ per-project section is STILL rendered below the unset message (spec
1983
+ §7.3a) — a project-only configuration is fully supported. When
1984
+ ``has_projects`` is False, every output mode is byte-identical to the
1985
+ pre-feature behavior (no ``projects`` key in --json, no extra lines).
1986
+ """
1727
1987
  c = _cctally()
1728
1988
  if getattr(args, "format", None):
1729
1989
  snap = _build_budget_no_budget_snapshot(args)
1990
+ snap = _append_project_share_rows(snap, project_rows, has_projects)
1730
1991
  c._share_render_and_emit(snap, args)
1731
1992
  return 0
1732
1993
  if getattr(args, "json", False):
1733
- print(json.dumps({
1994
+ payload = {
1734
1995
  "schemaVersion": _BUDGET_JSON_SCHEMA_VERSION,
1735
1996
  "status": "unset",
1736
1997
  "weekly_usd": None,
1737
- }))
1998
+ }
1999
+ if has_projects:
2000
+ _append_project_json(payload, project_rows)
2001
+ print(json.dumps(payload))
1738
2002
  return 0
1739
2003
  print("No weekly budget set. Set one with: cctally budget set <amount>.")
2004
+ _print_project_section_or_note(
2005
+ project_rows, has_projects, project_window_resolved, args
2006
+ )
1740
2007
  return 0
1741
2008
 
1742
2009
 
@@ -1821,9 +2088,16 @@ def _budget_alerts_line(budget_cfg, status) -> str:
1821
2088
  return f" Alerts: on · thresholds {thr_str} · {tail}"
1822
2089
 
1823
2090
 
1824
- def _budget_emit_json(budget_cfg, inputs, status) -> int:
2091
+ def _budget_emit_json(
2092
+ budget_cfg, inputs, status, *, project_rows=None, has_projects=False
2093
+ ) -> int:
1825
2094
  """Emit the full BudgetStatus + config echo + window as JSON (schemaVersion 1).
1826
- Window timestamps are `…Z`, ignoring display.tz (every --json is UTC)."""
2095
+ Window timestamps are `…Z`, ignoring display.tz (every --json is UTC).
2096
+
2097
+ When per-project budgets are configured (``has_projects``), an additive
2098
+ ``projects: [...]`` array is appended — ADDITIVE only, NO schemaVersion
2099
+ bump (spec §7.4). Absent when projects are empty so the global --json stays
2100
+ byte-identical."""
1827
2101
  payload = {
1828
2102
  "schemaVersion": _BUDGET_JSON_SCHEMA_VERSION,
1829
2103
  "status": "ok",
@@ -1846,16 +2120,212 @@ def _budget_emit_json(budget_cfg, inputs, status) -> int:
1846
2120
  "low_confidence": status.low_confidence,
1847
2121
  "crossed_thresholds": list(status.crossed_thresholds),
1848
2122
  }
2123
+ if has_projects:
2124
+ _append_project_json(payload, project_rows)
1849
2125
  print(json.dumps(payload))
1850
2126
  return 0
1851
2127
 
1852
2128
 
2129
+ def _build_project_budget_rows(conn, budget_cfg, now_utc):
2130
+ """Build per-project budget status dicts for the configured projects.
2131
+
2132
+ Returns ``None`` when no budget week window resolves (no usage snapshot
2133
+ yet) — the caller renders the "no usage data yet this week" note instead
2134
+ of a table. Otherwise a list of dicts (one per configured project),
2135
+ SORTED by ``consumption_pct`` descending, each carrying the same verdict
2136
+ fields as the global status (from one ``compute_budget_status`` codepath).
2137
+
2138
+ Spend is the shared ``_sum_cost_by_project`` scan over the current week
2139
+ ``[week_start_at, now]``; the recent-rate input is a SECOND scan over the
2140
+ CLAMPED trailing-24h window ``[max(week_start_at, now-24h), now]``. The
2141
+ clamp at ``week_start_at`` is MANDATORY (spec §7.1): without it a fresh
2142
+ week (``now < week_start + 24h``) pulls pre-reset spend into
2143
+ ``rate_recent`` and false-WARN/OVERs a project. Mirrors the global
2144
+ ``_build_budget_status_inputs`` (forecast.py) exactly.
2145
+
2146
+ A configured ``project_key`` with no matching entry this week (deleted /
2147
+ moved / never-matched repo, spec §7.2) is absent from both scan maps →
2148
+ ``spent=0`` / ``recent_24h=0`` → a ``$0 / 0% / ok`` row, never an error.
2149
+ """
2150
+ c = _cctally()
2151
+ window = _resolve_current_budget_window(conn, now_utc)
2152
+ if window is None:
2153
+ return None
2154
+ week_start_at, week_end_at = window
2155
+ week = c._sum_cost_by_project(week_start_at, now_utc, mode="auto")
2156
+ recent_start = max(week_start_at, now_utc - dt.timedelta(hours=24))
2157
+ last24h = c._sum_cost_by_project(recent_start, now_utc, mode="auto")
2158
+ thresholds = tuple(budget_cfg["alert_thresholds"])
2159
+
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"])
2164
+
2165
+ rows = []
2166
+ for key, target in budget_cfg["projects"].items():
2167
+ inputs = c.BudgetInputs(
2168
+ target_usd=float(target),
2169
+ spent_usd=float(week.get(key, 0.0)),
2170
+ recent_24h_usd=float(last24h.get(key, 0.0)),
2171
+ week_start_at=week_start_at,
2172
+ week_end_at=week_end_at,
2173
+ now=now_utc,
2174
+ alert_thresholds=thresholds,
2175
+ )
2176
+ status = c.compute_budget_status(inputs)
2177
+ label = labels[key]
2178
+ rows.append({
2179
+ "project": label,
2180
+ "project_key": key,
2181
+ "budget_usd": float(target),
2182
+ "spent_usd": status.spent_usd,
2183
+ "consumption_pct": status.consumption_pct,
2184
+ "verdict": status.verdict,
2185
+ "low_confidence": status.low_confidence,
2186
+ })
2187
+ rows.sort(key=lambda r: r["consumption_pct"], reverse=True)
2188
+ return rows
2189
+
2190
+
2191
+ def _render_project_budget_section(rows, *, color: bool) -> str:
2192
+ """Render the per-project budget table as plain aligned text below the
2193
+ global status block (spec §7.3). Columns:
2194
+ ``Project · Budget · Spent · Used % · Verdict`` (LOW CONF cue), already
2195
+ sorted by Used % desc by ``_build_project_budget_rows``."""
2196
+ c = _cctally()
2197
+ headers = ["Project", "Budget", "Spent", "Used %", "Verdict"]
2198
+ body = []
2199
+ for r in rows:
2200
+ verdict_label = {"ok": "OK", "warn": "WARN", "over": "OVER"}.get(
2201
+ r["verdict"], r["verdict"].upper()
2202
+ )
2203
+ if r["low_confidence"]:
2204
+ verdict_label += " (LOW CONF)"
2205
+ body.append([
2206
+ r["project"],
2207
+ f"${r['budget_usd']:,.2f}",
2208
+ f"${r['spent_usd']:,.2f}",
2209
+ f"{r['consumption_pct']:.1f}%",
2210
+ verdict_label,
2211
+ ])
2212
+ # Column widths sized to content (header + every cell).
2213
+ widths = [len(h) for h in headers]
2214
+ for cells in body:
2215
+ for i, cell in enumerate(cells):
2216
+ widths[i] = max(widths[i], len(cell))
2217
+ # Project + Verdict left-aligned; the three money/percent columns right.
2218
+ aligns = ["<", ">", ">", ">", "<"]
2219
+
2220
+ def _fmt_row(cells):
2221
+ return " ".join(
2222
+ f"{cell:{aligns[i]}{widths[i]}}" for i, cell in enumerate(cells)
2223
+ )
2224
+
2225
+ lines = ["", "Per-project budgets:", "", (" " + _fmt_row(headers)).rstrip()]
2226
+ for cells, r in zip(body, rows):
2227
+ rendered = (" " + _fmt_row(cells)).rstrip()
2228
+ if color:
2229
+ code = _budget_verdict_ansi_code(r["verdict"])
2230
+ rendered = c._style_ansi(rendered, code, color)
2231
+ lines.append(rendered)
2232
+ return "\n".join(lines)
2233
+
2234
+
2235
+ def _print_project_section_or_note(rows, has_projects, window_resolved, args):
2236
+ """Terminal helper: when projects are configured, print either the
2237
+ per-project table (window resolved) or a brief no-data note (parallel to
2238
+ the global no-data text, spec §7.3a). No-op when projects are empty so the
2239
+ existing global terminal output stays byte-identical."""
2240
+ if not has_projects:
2241
+ return
2242
+ c = _cctally()
2243
+ if not window_resolved:
2244
+ print("\nPer-project budgets: no usage data yet this week.")
2245
+ return
2246
+ print(_render_project_budget_section(rows, color=c._supports_color_stdout()))
2247
+
2248
+
2249
+ def _append_project_share_rows(snap, rows, has_projects):
2250
+ """Append per-project ProjectCell rows to a budget ShareSnapshot so the
2251
+ share-output anonymization chokepoint (``_lib_share._scrub``) rewrites the
2252
+ basenames under default output and reveals them under ``--reveal-projects``
2253
+ (spec §7.5). No-op when projects are empty → existing share goldens stay
2254
+ byte-identical. Project names go through ``ProjectCell`` (the single
2255
+ anonymization chokepoint); the [Anonymization fails closed] invariant
2256
+ applies."""
2257
+ if not has_projects or not rows:
2258
+ return snap
2259
+ c = _cctally()
2260
+ _lib_share = c._share_load_lib()
2261
+ # Reuse the snapshot's 2-col (Metric/Value) shape but render each project
2262
+ # as a ProjectCell in the metric column + its spend in the value.
2263
+ extra_rows = []
2264
+ # A header-ish separator row keeps the per-project block visually distinct
2265
+ # without changing the column schema.
2266
+ extra_rows.append(_lib_share.Row(cells={
2267
+ "metric": _lib_share.TextCell("— Per-project budgets —"),
2268
+ "value": _lib_share.TextCell(""),
2269
+ }))
2270
+ for r in rows:
2271
+ verdict = r["verdict"].upper()
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.
2276
+ extra_rows.append(_lib_share.Row(cells={
2277
+ "metric": _lib_share.ProjectCell(r["project"], rank_cost=r["spent_usd"]),
2278
+ "value": _lib_share.TextCell(
2279
+ f"${r['spent_usd']:,.2f} / ${r['budget_usd']:,.2f} "
2280
+ f"({r['consumption_pct']:.0f}%) {verdict}"
2281
+ ),
2282
+ }))
2283
+ return _replace_snapshot_rows(snap, tuple(snap.rows) + tuple(extra_rows))
2284
+
2285
+
2286
+ def _replace_snapshot_rows(snap, rows):
2287
+ """Return a copy of ``snap`` with ``rows`` replaced (frozen dataclass)."""
2288
+ return replace(snap, rows=rows)
2289
+
2290
+
2291
+ def _append_project_json(payload: dict, project_rows) -> None:
2292
+ """Attach the additive ``projects[]`` array to a budget ``--json`` payload
2293
+ (spec §7.4). Always present when this helper is called (the caller has
2294
+ already gated on ``has_projects``); empty ``project_rows`` → ``[]`` so a
2295
+ project-only configuration with no resolvable rows still emits the key.
2296
+ Single chokepoint for the three ``--json`` paths (full status / no-data /
2297
+ unset) so the append shape stays identical across them."""
2298
+ payload["projects"] = (
2299
+ _project_rows_json(project_rows) if project_rows else []
2300
+ )
2301
+
2302
+
2303
+ def _project_rows_json(rows) -> list:
2304
+ """Project the per-project status dicts onto the additive ``projects[]``
2305
+ JSON shape (spec §7.4): real paths, no anonymization (every --json emits
2306
+ real values, like ``project --json``)."""
2307
+ return [
2308
+ {
2309
+ "project": r["project"],
2310
+ "project_key": r["project_key"],
2311
+ "budget_usd": r["budget_usd"],
2312
+ "spent_usd": r["spent_usd"],
2313
+ "consumption_pct": r["consumption_pct"],
2314
+ "verdict": r["verdict"],
2315
+ "low_confidence": r["low_confidence"],
2316
+ }
2317
+ for r in rows
2318
+ ]
2319
+
2320
+
1853
2321
  def _build_budget_snapshot(args, budget_cfg, inputs, status):
1854
2322
  """Build a `_lib_share.ShareSnapshot` (cmd="budget") for `--format` output.
1855
2323
 
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)`."""
2324
+ This builds the GLOBAL budget rows only; when per-project budgets are
2325
+ configured, `_append_project_share_rows` appends ProjectCell rows so
2326
+ `--reveal-projects` reveals (or `_scrub` anonymizes) the per-project
2327
+ basenames via the share chokepoint. No parallel renderer; the gate calls
2328
+ `_share_render_and_emit(snap, args)`."""
1859
2329
  c = _cctally()
1860
2330
  _lib_share = c._share_load_lib()
1861
2331
  tz_label = c._share_display_tz_label(getattr(args, "_resolved_tz", None))