Skip to content
8 changes: 7 additions & 1 deletion integrations/antigravity/skills/lemoncrow/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,18 @@ Global scope by default. Add `--workspace <dir>` only if the user names a specif

Unknown key → run `lc settings show`, relay the valid keys.

5. **"what are my savings?" / cost questions** — shell available (Claude Code, Codex CLI): run `lc usage` (add `optimize` for savings analysis, `optimize detail` for the per-operation breakdown) and relay it. No shell (chat-only host): call the `statusline_segment` tool by exact name, not through the `tool` broker (which refuses it) — it returns a markdown panel, relay it verbatim:
5. **"what are my savings?" / cost questions** — shell available (Claude Code, Codex CLI): run `lc usage` (add `optimize` for savings analysis, `optimize detail` for the per-operation breakdown) and relay it. No shell (chat-only host): call the `statusline_segment` tool by exact name — it returns a markdown panel, relay it verbatim:

```json
{"name": "statusline_segment", "arguments": {"format": "markdown"}}
```

Host can only call the tools it lists? Go through the `tool` broker with `read_only: true`, which it requires; the totals can trail the newest session rows:

```json
{"name": "tool", "arguments": {"action": "call", "name": "statusline_segment", "arguments": {"format": "markdown", "read_only": true}}}
```

`format` accepts `markdown` (chat panel), `json` (raw report), `segment` (one statusline frame). Never recompute or restate the numbers.

6. **Any other verb** (e.g. "run benchmark X") → discover first, then run: `lc <topic> --help` (or `lc help <topic>`) to find the exact subcommand and flags, execute it, relay output. Never guess flags.
Expand Down
63 changes: 56 additions & 7 deletions integrations/claude/plugin/hooks/mcp_read_allow.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,17 @@
``codemod``, ``memory``, ``compact``, ``verify``, ``agent``, ``workflow``) is
omitted and keeps prompting normally. ``tool`` is omitted too: it dispatches to
any rarely-used lc tool by name, including write-capable ones, so allowing it
would launder the whole surface through one decision.
would launder the whole surface through one decision. ``search`` (which caches
every query in the workspace search cache) and ``context`` (which records the
task on the session ledger) are writers under PRD-739 FR4, so they are omitted
as well.

What this list holds has to stay inside what the MCP ``tool`` broker will run,
network tools aside -- the broker refuses those for reach, not for writes. That
agreement is pinned by ``tests/integrations/test_mcp_read_allow_hook.py``, not
by a shared import: Claude Code runs this file as a subprocess in plugin
installs where ``lemoncrow`` is not importable, so it stays standard-library
only.

Stays silent (no decision at all) for every other tool, so it never overrides a
user's own deny rule for something outside this list.
Expand All @@ -39,24 +49,63 @@
{
"blame",
"code_search",
"context",
"graph",
"grep",
"orient",
"read",
"relations",
"search",
"web_fetch",
}
)

# ``graph`` is one name over many operations, so the tool alone does not say
# whether a call reads: ``index_docs`` writes the design-doc store, ``pr_risk``
# folds each changed file into the machine-wide semantic file index,
# ``recall_docs`` embeds its query through the configured embedder, and
# ``enable`` switches doc indexing on. Only the kinds that read the code index
# or git history are auto-allowed; the rest keep prompting.
_GRAPH_READ_ONLY_KINDS = frozenset(
{
"blast_radius",
"centrality",
"commit_provenance",
"coupling",
"cycles",
"dead_code",
"design_gaps",
"topology",
"verify_design",
}
)
_GRAPH_DEFAULT_KIND = "blast_radius"


def _graph_reads_only(tool_input: Any) -> bool:
"""True when this ``graph`` call names a kind that only reads.

def _read_only_tool(tool_name: str) -> str | None:
"""Return the bare lc tool name when ``tool_name`` is an allowed read tool."""
Unreadable arguments count as not read-only: an omitted ``tool_input`` would
otherwise auto-allow whatever kind the call actually carried. ``kind`` is
raw model-supplied JSON, so a non-string one is judged rather than hashed --
the same shape the broker's own vetting uses -- because a ``list``/``dict``
would raise out of the membership test and out of the hook.
"""
if not isinstance(tool_input, dict) or "enable" in tool_input:
return False
kind = tool_input.get("kind", _GRAPH_DEFAULT_KIND)
return isinstance(kind, str) and kind in _GRAPH_READ_ONLY_KINDS


def _read_only_tool(tool_name: str, tool_input: Any = None) -> str | None:
"""Return the bare lc tool name when this call is an allowed read call."""
parts = tool_name.split("__")
if len(parts) != 3 or parts[0] != "mcp" or parts[1] not in _SERVERS:
return None
return parts[2] if parts[2] in _READ_ONLY_TOOLS else None
tool = parts[2]
if tool not in _READ_ONLY_TOOLS:
return None
if tool == "graph" and not _graph_reads_only(tool_input):
return None
return tool


def _allow(reason: str) -> None:
Expand All @@ -82,7 +131,7 @@ def main() -> int:
return 0
if not isinstance(payload, dict):
return 0
tool = _read_only_tool(str(payload.get("tool_name") or ""))
tool = _read_only_tool(str(payload.get("tool_name") or ""), payload.get("tool_input"))
if tool is None:
return 0
_allow(f"lc {tool} is read-only (no writes, no shell); auto-allowed in every mode including Plan Mode.")
Expand Down
8 changes: 7 additions & 1 deletion integrations/claude/plugin/skills/lemoncrow/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,18 @@ Global scope by default. Add `--workspace <dir>` only if the user names a specif

Unknown key → run `lc settings show`, relay the valid keys.

5. **"what are my savings?" / cost questions** — shell available (Claude Code, Codex CLI): run `lc usage` (add `optimize` for savings analysis, `optimize detail` for the per-operation breakdown) and relay it. No shell (chat-only host): call the `statusline_segment` tool by exact name, not through the `tool` broker (which refuses it) — it returns a markdown panel, relay it verbatim:
5. **"what are my savings?" / cost questions** — shell available (Claude Code, Codex CLI): run `lc usage` (add `optimize` for savings analysis, `optimize detail` for the per-operation breakdown) and relay it. No shell (chat-only host): call the `statusline_segment` tool by exact name — it returns a markdown panel, relay it verbatim:

```json
{"name": "statusline_segment", "arguments": {"format": "markdown"}}
```

Host can only call the tools it lists? Go through the `tool` broker with `read_only: true`, which it requires; the totals can trail the newest session rows:

```json
{"name": "tool", "arguments": {"action": "call", "name": "statusline_segment", "arguments": {"format": "markdown", "read_only": true}}}
```

`format` accepts `markdown` (chat panel), `json` (raw report), `segment` (one statusline frame). Never recompute or restate the numbers.

6. **Any other verb** (e.g. "run benchmark X") → discover first, then run: `lc <topic> --help` (or `lc help <topic>`) to find the exact subcommand and flags, execute it, relay output. Never guess flags.
Expand Down
8 changes: 7 additions & 1 deletion integrations/codex/plugin/skills/lemoncrow/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,18 @@ Global scope by default. Add `--workspace <dir>` only if the user names a specif

Unknown key → run `lc settings show`, relay the valid keys.

5. **"what are my savings?" / cost questions** — shell available (Claude Code, Codex CLI): run `lc usage` (add `optimize` for savings analysis, `optimize detail` for the per-operation breakdown) and relay it. No shell (chat-only host): call the `statusline_segment` tool by exact name, not through the `tool` broker (which refuses it) — it returns a markdown panel, relay it verbatim:
5. **"what are my savings?" / cost questions** — shell available (Claude Code, Codex CLI): run `lc usage` (add `optimize` for savings analysis, `optimize detail` for the per-operation breakdown) and relay it. No shell (chat-only host): call the `statusline_segment` tool by exact name — it returns a markdown panel, relay it verbatim:

```json
{"name": "statusline_segment", "arguments": {"format": "markdown"}}
```

Host can only call the tools it lists? Go through the `tool` broker with `read_only: true`, which it requires; the totals can trail the newest session rows:

```json
{"name": "tool", "arguments": {"action": "call", "name": "statusline_segment", "arguments": {"format": "markdown", "read_only": true}}}
```

`format` accepts `markdown` (chat panel), `json` (raw report), `segment` (one statusline frame). Never recompute or restate the numbers.

6. **Any other verb** (e.g. "run benchmark X") → discover first, then run: `lc <topic> --help` (or `lc help <topic>`) to find the exact subcommand and flags, execute it, relay output. Never guess flags.
Expand Down
8 changes: 7 additions & 1 deletion integrations/skills/lemoncrow/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,18 @@ Global scope by default. Add `--workspace <dir>` only if the user names a specif

Unknown key → run `lc settings show`, relay the valid keys.

5. **"what are my savings?" / cost questions** — shell available (Claude Code, Codex CLI): run `lc usage` (add `optimize` for savings analysis, `optimize detail` for the per-operation breakdown) and relay it. No shell (chat-only host): call the `statusline_segment` tool by exact name, not through the `tool` broker (which refuses it) — it returns a markdown panel, relay it verbatim:
5. **"what are my savings?" / cost questions** — shell available (Claude Code, Codex CLI): run `lc usage` (add `optimize` for savings analysis, `optimize detail` for the per-operation breakdown) and relay it. No shell (chat-only host): call the `statusline_segment` tool by exact name — it returns a markdown panel, relay it verbatim:

```json
{"name": "statusline_segment", "arguments": {"format": "markdown"}}
```

Host can only call the tools it lists? Go through the `tool` broker with `read_only: true`, which it requires; the totals can trail the newest session rows:

```json
{"name": "tool", "arguments": {"action": "call", "name": "statusline_segment", "arguments": {"format": "markdown", "read_only": true}}}
```

`format` accepts `markdown` (chat panel), `json` (raw report), `segment` (one statusline frame). Never recompute or restate the numbers.

6. **Any other verb** (e.g. "run benchmark X") → discover first, then run: `lc <topic> --help` (or `lc help <topic>`) to find the exact subcommand and flags, execute it, relay output. Never guess flags.
Expand Down
2 changes: 1 addition & 1 deletion src/lemoncrow/core/capabilities/code_context_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ class IndexStats(BaseModel):
imports_indexed: int
index_version: int = 0
# True when the Free-tier repo-size cap truncated indexing (see
# code_context/engine.py's _FREE_TIER_MAX_FILES). Always False on Pro.
# infra/code_intel/inclusion.py's FREE_TIER_MAX_FILES). Always False on Pro.
capped: bool = False


Expand Down
19 changes: 14 additions & 5 deletions src/lemoncrow/core/capabilities/plugin_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -615,7 +615,9 @@ def _savings_cap_usd(subscription: dict[str, Any]) -> float | None:
return None


def compute_usage_meter(root: str | Path, *, subscription: dict[str, Any] | None = None) -> dict[str, Any]:
def compute_usage_meter(
root: str | Path, *, subscription: dict[str, Any] | None = None, fold: bool = True
) -> dict[str, Any]:
"""Price trailing-window usage against the plan's monthly limit.

Realized spend and savings come from :func:`aggregate_window_savings` (the
Expand All @@ -628,6 +630,8 @@ def compute_usage_meter(root: str | Path, *, subscription: dict[str, Any] | None

``monthlyLimitInUsd <= 0`` (or absent) means "no local limit": spend and
savings are still reported, but ``warning``/``overLimit`` stay False.

``fold=False`` leaves the savings aggregate alone (see :func:`aggregate_window_savings`).
"""
root_path = Path(root)
if subscription is None:
Expand All @@ -651,7 +655,7 @@ def compute_usage_meter(root: str | Path, *, subscription: dict[str, Any] | None
try:
from lemoncrow.core.capabilities.savings_summary import aggregate_window_savings

window = aggregate_window_savings(root_path, days=BILLING_WINDOW_DAYS)
window = aggregate_window_savings(root_path, days=BILLING_WINDOW_DAYS, fold=fold)
spend_usd = round(max(0.0, float(window.spend_usd)), 4)
savings_usd = round(max(0.0, float(window.saved_usd)), 4)
except Exception:
Expand Down Expand Up @@ -5094,13 +5098,18 @@ def build_savings_report(
root: str | Path,
*,
session_id: str | None = None,
fold: bool = True,
) -> dict[str, Any]:
"""Compose the savings/cost report.

- With ``session_id``: per-session live display, sourced from the Claude
transcript JSONL (tool_result.content[].saved entries).
- Without ``session_id``: all-session analytics aggregate from the
routing/compaction event log.

``fold=False`` leaves the savings aggregate alone: the windowed totals are
read from it as it stands, without folding session ledgers it has not seen
or persisting it, so they can trail rows written since the last fold.
"""
root_path = Path(root)
session = aggregate_session_stats(root_path, session_id=session_id)
Expand Down Expand Up @@ -5139,7 +5148,7 @@ def build_savings_report(
# (sessions/*/savings.jsonl) so the CLI agrees with the statusline and
# web Savings page. Routing credit stays sourced from the analytics log.
analytics = load_live_savings_summary(root_path)
lifetime_w = aggregate_window_savings(root_path, days=36500)
lifetime_w = aggregate_window_savings(root_path, days=36500, fold=fold)
tokens_saved = lifetime_w.tokens_saved
calls_avoided = lifetime_w.calls_saved
saved_usd = lifetime_w.saved_usd
Expand Down Expand Up @@ -5186,14 +5195,14 @@ def build_savings_report(
lifetime.setdefault("tokens_saved", tokens_saved)
lifetime.setdefault("saved_usd", saved_usd)
subscription = resolve_subscription(root_path)
subscription = compute_usage_meter(root_path, subscription=subscription)
subscription = compute_usage_meter(root_path, subscription=subscription, fold=fold)
ab_calibration = _summarize_ab_calibration(root_path)

# --- Summary breakdown (1D, 7D, 30D) ---
# Realized savings from the per-session ledger (sessions/*/savings.jsonl) —
# the same source as the statusline and stop hook.
def _window(d: int) -> dict[str, Any]:
w = aggregate_window_savings(root_path, days=d)
w = aggregate_window_savings(root_path, days=d, fold=fold)
return {
"calls": w.calls_saved,
"usd": round(w.saved_usd, 2),
Expand Down
39 changes: 33 additions & 6 deletions src/lemoncrow/core/capabilities/savings_summary.py
Original file line number Diff line number Diff line change
Expand Up @@ -2551,20 +2551,21 @@ def _bump_historical_savings_cache(row: dict[str, Any]) -> None:


def _read_historical_savings(
days: int, root: Path
days: int, root: Path, *, fold: bool = True
) -> tuple[float, int, int, int, float, float, float, float, int, int]:
"""Windowed savings/spend for ONE trailing window — blocking surface.

Used by :func:`aggregate_window_savings` (CLI breakdown, web Savings page,
reports): reconciles any session ledgers the persisted aggregate has not
folded yet before answering, so explicit surfaces always reflect the
on-disk ledger. The statusline path uses the non-blocking
:func:`_read_historical_savings_many` directly.
:func:`_read_historical_savings_many` directly. ``fold=False`` skips that
reconcile and writes nothing.

Returns (savings_usd, tokens_saved, calls_saved, turns_saved, spend_usd,
carry_usd, routing_usd, read_saved_usd, read_saved_tokens, carry_tokens).
"""
return _read_historical_savings_many((int(days),), root, block=True)[int(days)]
return _read_historical_savings_many((int(days),), root, block=True, fold=fold)[int(days)]


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -2963,6 +2964,19 @@ def _get_aggregate_state(root: Path) -> dict[str, Any]:
return agg


def _aggregate_as_it_stands(root: Path) -> dict[str, Any]:
"""This process's aggregate for *root*, else the persisted one, else empty.

The read-only counterpart of :func:`_get_aggregate_state`, whose bootstrap
folds and persists: this never does either.
"""
with _aggregate_lock:
cached = _aggregate_state.get(str(root))
if cached is not None:
return cached
return _load_persisted_aggregate(root) or _empty_savings_aggregate()


def _refresh_aggregate_state(root: Path) -> None:
"""Reconcile, swap the in-memory aggregate, and rewrite cached window totals."""
root_str = str(root)
Expand Down Expand Up @@ -3000,7 +3014,7 @@ def _maybe_refresh_aggregate(root: Path, *, block: bool) -> None:


def _read_historical_savings_many(
days_list: tuple[int, ...], root: Path, *, block: bool = False
days_list: tuple[int, ...], root: Path, *, block: bool = False, fold: bool = True
) -> dict[int, tuple[float, int, int, int, float, float, float, float, int, int]]:
"""Windowed savings for SEVERAL trailing windows from the day-bucketed
aggregate — never a sessions/** scan on the caller's thread (except a
Expand All @@ -3012,6 +3026,11 @@ def _read_historical_savings_many(
current totals (live rows already folded in O(1) by
:func:`_bump_historical_savings_cache`) and refresh in a background thread
that rewrites the cache for the next read.
``fold=False`` (read-only surfaces): writes nothing -- no reconcile, no
bootstrap, no background refresh. It answers from the aggregate as it
stands, which trails any ledger rows nothing has folded yet, and it caches
nothing, so that trailing answer is never what a blocking surface is served
for the next TTL.
"""
now = time.time()
root_str = str(root)
Expand All @@ -3025,6 +3044,11 @@ def _read_historical_savings_many(
missing.append(days)
if not missing:
return results
if not fold:
agg = _aggregate_as_it_stands(root)
for days in missing:
results[days] = _window_from_aggregate(agg, days, now)
return results
_get_aggregate_state(root)
_maybe_refresh_aggregate(root, block=block)
with _aggregate_lock:
Expand Down Expand Up @@ -3093,7 +3117,7 @@ def total_saved_usd(self) -> float:
return self.saved_usd + self.carry_usd


def aggregate_window_savings(root: str | Path, *, days: int) -> WindowSavings:
def aggregate_window_savings(root: str | Path, *, days: int, fold: bool = True) -> WindowSavings:
"""Realized savings over the last *days* from the canonical per-session ledger.

Single source of truth for every windowed savings surface (CLI breakdown,
Expand All @@ -3103,9 +3127,12 @@ def aggregate_window_savings(root: str | Path, *, days: int) -> WindowSavings:
(composition time), not inside the per-row day buckets, mirroring how
:func:`compute_savings_summary` folds ``routing_saved_usd`` into its
``saved_usd`` — both still expose the routing figure separately too.

``fold=False`` writes nothing, and trails ledger rows no fold has seen yet
(see :func:`_read_historical_savings_many`).
"""
usd, tok, calls, turns, spend, carry, routing, read_usd, read_tok, carry_tok = _read_historical_savings(
int(days), Path(root)
int(days), Path(root), fold=fold
)
return WindowSavings(
saved_usd=round(usd + routing, 6),
Expand Down
Loading
Loading