Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions skills/pr-cost/INSTALL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Install PR cost hooks

Annotates a newly created GitHub PR with estimated AI session cost. Default is
**ledger only** (`~/.local/share/pr-cost/ledger.jsonl`). GitHub comments stay
off unless `PR_COST_HOOK_LIVE=1`.

Collector:

```bash
/opt/homebrew/bin/python3 skills/pr-cost/scripts/pr_cost_collect.py from-hook --harness <cursor|claude|codex>
```

## Cursor (installed on this machine)

User-global:

- `~/.cursor/hooks.json` — `afterShellExecution` matcher `\bgh\s+pr\s+create\b`
- `~/.cursor/hooks/pr-cost-from-hook.sh` — fail-open wrapper → collector `--harness cursor`

Reload: Cursor watches `hooks.json`. If it does not fire, restart Cursor and
check the Hooks output channel.

Versioned copy: `adapters/cursor/`.

## Claude Code (installed on this machine)

- `~/.claude/settings.json` `hooks.PostToolUse` matcher `Bash(gh pr create:*)`
- `~/.claude/hooks/pr-cost-from-hook.sh` → `adapters/claude/v1/pr_cost_from_hook.py`

Existing worklog `PreCompact` / `SessionEnd` hooks must stay. Do not set
`attribution.pr`.

## Codex (opt-in, not on PATH)

Codex has no native PR-create hook. Prepend only in shells where Codex runs `gh`:

```bash
export PATH="$HOME/Documents/oss/dotfiles/skills/pr-cost/adapters/codex/bin:$PATH"
```

See `adapters/codex/README.md`. Do not shadow `/opt/homebrew/bin/gh` globally.

## Enable live PR comments (off by default)

```bash
export PR_COST_HOOK_LIVE=1
```

The collector posts an idempotent `gh pr comment`. Duplicate `pr_url` +
`session_id` rows are skipped.

To dogfood a **Claude** cost comment on an open PR, paste
`handovers/claude-comment-pr-cost.md` into a new Claude Code session. That
prompt sums `message.usage` from the session JSONL and runs `annotate` with
`PR_COST_HOOK_LIVE=1`.

Codex sessions expose running totals as `event_msg.type = token_count` in
`~/.codex/sessions/**/rollout-*.jsonl`. Read the last one with
`scripts/codex_session_usage.py`.

## Verify without a live PR

```bash
/opt/homebrew/bin/python3 -m unittest discover -s skills/pr-cost/tests -q
```

That suite includes a `from-hook` fixture that writes a temp ledger and fails
if `gh` is invoked while `PR_COST_HOOK_LIVE` is unset.
115 changes: 115 additions & 0 deletions skills/pr-cost/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
---
name: pr-cost
description: Collect a typed AI cost payload for a newly created GitHub PR, persist it to a local ledger, and optionally post an idempotent PR comment when live writes are explicitly enabled.
---

# pr-cost

Use this skill from harness-specific hook adapters after a successful `gh pr create`.
It is dry by default: it always prefers the local ledger, and it only writes a
GitHub PR comment when `PR_COST_HOOK_LIVE=1`.

Install and verify: [INSTALL.md](INSTALL.md). Adapters live in `adapters/{cursor,claude,codex}/`.

To comment Claude cost on an already-open PR, paste
[handovers/claude-comment-pr-cost.md](handovers/claude-comment-pr-cost.md) into a
fresh Claude Code session.

## Files

- Collector: `scripts/pr_cost_collect.py`
- Tests: `tests/test_pr_cost_collect.py`
- Fixtures: `tests/fixtures/`

## Contract

The collector emits one JSON object with this required shape:

```json
{
"schema_version": "pr-cost/v1",
"harness": "claude | cursor | codex",
"confidence": "metered | estimated | unavailable",
"usd": 1.23,
"tokens_in": 1200,
"tokens_out": 3400,
"model": "claude-sonnet-4-20250514",
"session_id": "session-123",
"window_start": "2026-08-20T19:00:00+00:00",
"window_end": "2026-08-20T19:05:00+00:00",
"pr_url": "https://github.com/owner/repo/pull/123",
"generated_at": "2026-08-20T19:05:01+00:00",
"notes": "optional"
}
```

`usd`, `tokens_in`, `tokens_out`, `model`, `session_id`, `pr_url`, and `notes`
may be `null` when the harness cannot supply them. The keys still remain
present so downstream adapters receive a stable typed contract.

## Privacy rules

- Never copy prompts, responses, file contents, or shell output beyond the PR URL.
- Never store API keys, tokens, auth headers, or repo-local secrets.
- Prefer safe metadata only: harness, model, token counts, session identifier,
bounded timestamps, PR URL, and a short note about confidence.
- Cursor and Codex adapters should treat unavailable data as `null`, not as a
reason to scrape unrelated local state.

## Harness guidance

- `cursor`: hook payload can detect `gh pr create`, but it does not expose
token or USD usage. Default confidence is `unavailable`.
- `claude`: `PostToolUse` can observe `gh pr create`. If an adapter already has
token or pricing inputs, pass them as CLI flags so the collector can emit an
`estimated` payload. Otherwise it will fall back to `unavailable`.
- `codex`: there is no native PR creation hook. Use a wrapper that feeds a
matching hook JSON shape to `from-hook`, or call `emit` / `annotate`
directly with explicit payload fields.

## Environment

- `PR_COST_LEDGER`: optional ledger override. Defaults to
`~/.local/share/pr-cost/ledger.jsonl`.
- `PR_COST_HOOK_LIVE=1`: enables live `gh pr comment` writes. Unset keeps the
collector dry and ledger-only.

## Commands

Validate and print a payload:

```bash
/opt/homebrew/bin/python3 scripts/pr_cost_collect.py emit \
--harness claude \
--confidence estimated \
--usd 1.23 \
--tokens-in 1200 \
--tokens-out 3400 \
--model claude-sonnet-4-20250514 \
--session-id session-123 \
--window-start 2026-08-20T19:00:00+00:00 \
--window-end 2026-08-20T19:05:00+00:00 \
--pr-url https://github.com/owner/repo/pull/123
```

Append to the ledger and optionally comment on the PR:

```bash
/opt/homebrew/bin/python3 scripts/pr_cost_collect.py annotate \
--fixture tests/fixtures/emit_valid.json
```

Run from a hook adapter by piping the native hook JSON to stdin:

```bash
printf '%s\n' '{"command":"gh pr create ...","exit_code":0,"stdout":"https://github.com/owner/repo/pull/123"}' \
| /opt/homebrew/bin/python3 scripts/pr_cost_collect.py from-hook \
--harness cursor
```

`from-hook` is fail-open by design:

- It ignores `gh pr view`, `gh pr comment`, and unrelated commands.
- It exits `0` on parse failures so the harness never blocks PR creation.
- It skips duplicate annotations when the ledger already contains the same
`pr_url` and `session_id`.
125 changes: 125 additions & 0 deletions skills/pr-cost/adapters/claude/v1/pr_cost_from_hook.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
#!/opt/homebrew/bin/python3
"""Normalize Claude PostToolUse payloads for the shared PR cost collector."""

from __future__ import annotations

import json
import subprocess
import sys
from typing import Any


PYTHON = "/opt/homebrew/bin/python3"
COLLECTOR = "/Users/fredtran/Documents/oss/dotfiles/skills/pr-cost/scripts/pr_cost_collect.py"


def _clean_string(value: Any) -> str | None:
if not isinstance(value, str):
return None
stripped = value.strip()
return stripped or None


def _clean_int(value: Any) -> int | None:
if isinstance(value, bool) or not isinstance(value, int):
return None
return value


def _clean_number(value: Any) -> float | int | None:
if isinstance(value, bool) or not isinstance(value, (int, float)):
return None
return value


def _exit_code(tool_response: dict[str, Any]) -> int | None:
explicit_exit = _clean_int(tool_response.get("exit_code"))
if explicit_exit is not None:
return explicit_exit
if tool_response.get("interrupted") is True:
return 130
return None


def normalize_payload(payload: dict[str, Any]) -> dict[str, Any]:
tool_input = payload.get("tool_input")
if not isinstance(tool_input, dict):
tool_input = {}

tool_response = payload.get("tool_response")
if not isinstance(tool_response, dict):
tool_response = {}

stdout = tool_response.get("stdout")
normalized = {
"command": _clean_string(tool_input.get("command")),
"stdout": stdout if isinstance(stdout, str) else "",
"exit_code": _exit_code(tool_response),
}

stderr = tool_response.get("stderr")
if isinstance(stderr, str):
normalized["stderr"] = stderr

return normalized


def collector_command(payload: dict[str, Any]) -> list[str]:
command = [PYTHON, COLLECTOR, "from-hook", "--harness", "claude"]

session_id = _clean_string(payload.get("session_id"))
if session_id is not None:
command.extend(["--session-id", session_id])

model = _clean_string(payload.get("model"))
if model is not None:
command.extend(["--model", model])

generated_at = _clean_string(payload.get("timestamp"))
if generated_at is not None:
command.extend(["--generated-at", generated_at])

usd = _clean_number(payload.get("usd"))
if usd is not None:
command.extend(["--usd", str(usd)])

tokens_in = _clean_int(payload.get("tokens_in"))
if tokens_in is not None:
command.extend(["--tokens-in", str(tokens_in)])

tokens_out = _clean_int(payload.get("tokens_out"))
if tokens_out is not None:
command.extend(["--tokens-out", str(tokens_out)])

return command


def main() -> int:
raw_input = sys.stdin.read()
if not raw_input.strip():
return 0

try:
payload = json.loads(raw_input)
except json.JSONDecodeError:
return 0

if not isinstance(payload, dict):
return 0

try:
subprocess.run(
collector_command(payload),
input=json.dumps(normalize_payload(payload)),
text=True,
capture_output=True,
check=False,
)
except Exception:
return 0

return 0


if __name__ == "__main__":
raise SystemExit(main())
47 changes: 47 additions & 0 deletions skills/pr-cost/adapters/codex/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Codex PR-cost adapter

Codex does not expose a native PR-creation hook, so this adapter uses the
least-bad fallback from the survey: an opt-in `gh` wrapper that watches for
successful `gh pr create` commands and forwards a hook-shaped JSON payload to:

```bash
/opt/homebrew/bin/python3 /Users/fredtran/Documents/oss/dotfiles/skills/pr-cost/scripts/pr_cost_collect.py from-hook --harness codex
```

The wrapper is versioned under this skill instead of inventing new
`~/.codex/config.toml` keys or replacing Codex's existing `notify` behavior.

## Install

Prepend this adapter directory to `PATH` for the shell where Codex runs `gh`:

```bash
export PATH="/Users/fredtran/Documents/oss/dotfiles/skills/pr-cost/adapters/codex/bin:$PATH"
```

If the real GitHub CLI is not `/opt/homebrew/bin/gh`, point the wrapper at it
explicitly:

```bash
export PR_COST_REAL_GH="/absolute/path/to/gh"
```

This task does not install the wrapper globally, does not edit `~/.codex`, and
does not set `PR_COST_HOOK_LIVE`.

## Behavior

- Calls the real `gh` binary and preserves its exit code, stdout, and stderr.
- Only invokes the collector for `gh pr create`.
- Uses the collector's existing fail-open `from-hook` path, so annotation
failures never block PR creation.
- Leaves PR comments disabled unless someone explicitly exports
`PR_COST_HOOK_LIVE=1` outside this adapter.

## Uninstall

Remove the adapter directory from `PATH`, or unset the override if you set one:

```bash
unset PR_COST_REAL_GH
```
Loading
Loading