Skip to content
Merged
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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,19 @@ All notable changes to this repository will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project follows SemVer for bundle versions.

## [0.42.0] - 2026-03-16

### Added

- Add a `specfact-code-review` reward ledger with Supabase-first persistence,
local JSON fallback, and `ledger update|status|reset` commands under
`specfact code review`.

### Changed

- Document the new ledger workflow, including the review-report pipe and the
offline fallback path used when Supabase is not configured.

## [0.41.5] - 2026-03-13

### Added
Expand Down
41 changes: 41 additions & 0 deletions docs/modules/code-review.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,47 @@ Operational note:
- the current development environment includes CrossHair, but installed-user
environments still need that executable available for the fast pass to run

## Ledger commands

The `specfact-code-review` bundle now includes a reward ledger that persists
review outcomes and exposes a small CLI surface under `specfact code review
ledger`.

### Command flow

Use the governed review report as the canonical input for ledger updates:

```bash
specfact code review run --json | specfact code review ledger update
specfact code review ledger status
specfact code review ledger reset --confirm
```

### Storage behavior

- When `SUPABASE_URL` and `SUPABASE_KEY` are present, the ledger writes review
runs to `ai_sync.review_runs` and appends ledger snapshots to
`ai_sync.reward_ledger`.
- The reviewed DDL lives with the bundle at
`packages/specfact-code-review/src/specfact_code_review/resources/supabase/review_ledger_ddl.sql`
instead of a repo-root infrastructure folder.
- When Supabase is unavailable or not configured, the bundle falls back to the
local JSON ledger at `~/.specfact/ledger.json`.
- `ledger status` prints coins, pass/block streaks, the last verdict, and the
top three violation rules seen so far.
- `ledger reset` only clears the local JSON fallback and requires `--confirm`.

### Local module development

If the CLI warns that bundled modules are missing or outdated while you are
testing local bundle changes, refresh the project-scope modules first:

```bash
specfact module init --scope project
```

Then rerun the ledger command from the same repository checkout.

## Review orchestration

`specfact_code_review.run.runner.run_review(files, no_tests=False)` orchestrates the
Expand Down
6 changes: 3 additions & 3 deletions packages/specfact-code-review/module-package.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name: nold-ai/specfact-code-review
version: 0.41.7
version: 0.42.0
commands:
- code
tier: official
Expand All @@ -12,5 +12,5 @@ description: Official SpecFact code review bundle package.
category: codebase
bundle_group_command: code
integrity:
checksum: sha256:f3384cd61ea1073d43b9e0b51b5bede967a199f0d93f8b94abe35dec3a80faaf
signature: 4f/CSwuvn/T20kHlema6JnBUH1vTcKDdC4UHfOJHZDg+TU1TzQfWi6ftFdjxzsnTb4EquR5ey5ARLOM0/6SHAw==
checksum: sha256:d1be400445c194a80caa44e711d82c582f6f3a94875d5c1f07f8744ed940b536
signature: 32d7FpwI2LDzVC9s4yax27lFmJiebAlQhgPrHBBgQgGHuW9Z5xLiER04gs2M+JUEA8OSqIRDP4IL+aDa5BXMDA==
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"""Reward-ledger package surface."""

from specfact_code_review.ledger.client import LedgerClient
from specfact_code_review.ledger.commands import app


__all__ = ["LedgerClient", "app"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
"""Reward-ledger persistence with Supabase-first and local JSON fallback."""

from __future__ import annotations

import json
from collections import Counter
from datetime import UTC, datetime
from pathlib import Path
from typing import Any, Literal

import requests
from beartype import beartype
from icontract import ensure, require
from pydantic import BaseModel, Field

from specfact_code_review.run.findings import FAIL, ReviewFinding, ReviewReport


LedgerVerdict = Literal["PASS", "PASS_WITH_ADVISORY", "FAIL"]
DEFAULT_AGENT = "claude-code"
DEFAULT_LOCAL_PATH = Path.home() / ".specfact" / "ledger.json"


class LedgerRun(BaseModel):
"""Persisted review-run payload."""

session_id: str = Field(..., description="Stable run identifier.")
issue_number: int | None = Field(default=None, description="Optional linked issue number.")
agent: str = Field(default=DEFAULT_AGENT, description="Agent name.")
changed_files: list[str] = Field(default_factory=list, description="Changed files captured in the run.")
score: int = Field(..., ge=0, le=120, description="Governed review score.")
reward_delta: int = Field(..., description="Raw reward delta from ReviewReport.")
verdict: LedgerVerdict = Field(..., description="Overall review verdict.")
findings_json: list[dict[str, Any]] = Field(default_factory=list, description="Serialized findings payload.")
house_rules_ver: int = Field(default=1, description="House-rules version observed during the run.")
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC), description="UTC creation timestamp.")


class LedgerState(BaseModel):
"""Current reward-ledger state."""

agent: str = Field(default=DEFAULT_AGENT, description="Agent name.")
cumulative_coins: float = Field(default=0.0, description="Accumulated reward coins.")
streak_pass: int = Field(default=0, ge=0, description="Current passing streak length.")
streak_block: int = Field(default=0, ge=0, description="Current blocking streak length.")
last_delta: int = Field(default=0, description="Most recent raw reward delta.")
last_verdict: LedgerVerdict | None = Field(default=None, description="Most recent review verdict.")
violation_counts: dict[str, int] = Field(default_factory=dict, description="Aggregated rule counts.")
runs: list[LedgerRun] = Field(default_factory=list, description="Locally persisted runs.")


class LedgerClient:
"""Persist review-run rewards to Supabase with a local JSON fallback."""

def __init__(
self,
*,
supabase_url: str | None = None,
supabase_key: str | None = None,
local_path: Path | None = None,
agent: str = DEFAULT_AGENT,
) -> None:
self._supabase_url = (supabase_url or "").rstrip("/")
self._supabase_key = supabase_key or ""
self._local_path = local_path or DEFAULT_LOCAL_PATH
self._agent = agent

@beartype
@require(lambda report: isinstance(report, ReviewReport), "report must be a ReviewReport")
@ensure(lambda result: isinstance(result, dict), "record_run must return a status dictionary")
def record_run(self, report: ReviewReport) -> dict[str, object]:
"""Record a review run and return the updated ledger status."""
current_state = self._read_supabase_state() if self._supabase_enabled else None
if current_state is None:
current_state = self._read_local_state()

updated_state, run_entry = self._apply_report(current_state, report)
if self._supabase_enabled and self._write_supabase(run_entry, updated_state):
return self._status_payload(updated_state)

self._write_local_state(updated_state)
return self._status_payload(updated_state)

@beartype
@ensure(lambda result: isinstance(result, dict), "get_status must return a status dictionary")
def get_status(self) -> dict[str, object]:
"""Return the current ledger status from Supabase or the local fallback."""
state = self._read_supabase_state()
if state is None:
state = self._read_local_state()
return self._status_payload(state)

@beartype
@ensure(lambda result: isinstance(result, bool), "reset_local must return a boolean")
def reset_local(self) -> bool:
"""Delete the local fallback ledger if it exists."""
if self._local_path.exists():
self._local_path.unlink()
return True

@property
def _supabase_enabled(self) -> bool:
return bool(self._supabase_url and self._supabase_key)

def _apply_report(self, current_state: LedgerState, report: ReviewReport) -> tuple[LedgerState, LedgerRun]:
run_entry = LedgerRun(
session_id=report.run_id,
agent=self._agent,
changed_files=self._changed_files_for(report),
score=report.score,
reward_delta=report.reward_delta or 0,
verdict=self._verdict_for(report),
findings_json=[finding.model_dump(mode="json") for finding in report.findings],
created_at=report.timestamp,
)
next_pass_streak = current_state.streak_pass + 1 if run_entry.verdict != FAIL else 0
next_block_streak = current_state.streak_block + 1 if run_entry.verdict == FAIL else 0

coin_delta = (run_entry.reward_delta or 0) / 10.0
if run_entry.verdict != FAIL and next_pass_streak >= 5:
coin_delta += 0.5
if run_entry.verdict == FAIL and next_block_streak >= 3:
coin_delta -= 1.0

violation_counts = Counter(current_state.violation_counts)
violation_counts.update(self._rule_counts(report.findings))

updated_state = LedgerState(
agent=self._agent,
cumulative_coins=round(current_state.cumulative_coins + coin_delta, 2),
streak_pass=next_pass_streak,
streak_block=next_block_streak,
last_delta=run_entry.reward_delta,
last_verdict=run_entry.verdict,
violation_counts=dict(violation_counts),
runs=[*current_state.runs, run_entry],
)
return updated_state, run_entry

def _changed_files_for(self, report: ReviewReport) -> list[str]:
return sorted({finding.file for finding in report.findings if finding.file})

def _verdict_for(self, report: ReviewReport) -> LedgerVerdict:
if report.overall_verdict is None:
return "PASS_WITH_ADVISORY"
return report.overall_verdict

def _rule_counts(self, findings: list[ReviewFinding]) -> Counter[str]:
return Counter(finding.rule for finding in findings)

def _read_local_state(self) -> LedgerState:
if not self._local_path.exists():
return LedgerState(agent=self._agent)
try:
payload = json.loads(self._local_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return LedgerState(agent=self._agent)
return LedgerState.model_validate(payload)

def _write_local_state(self, state: LedgerState) -> None:
self._local_path.parent.mkdir(parents=True, exist_ok=True)
self._local_path.write_text(state.model_dump_json(indent=2), encoding="utf-8")

def _read_supabase_state(self) -> LedgerState | None:
if not self._supabase_enabled:
return None
try:
response = requests.get(
f"{self._supabase_url}/rest/v1/reward_ledger",
headers=self._supabase_headers(),
params={
"agent": f"eq.{self._agent}",
"order": "updated_at.desc",
"limit": "1",
},
timeout=10,
)
response.raise_for_status()
payload = response.json()
except (requests.RequestException, ValueError):
return None

if not isinstance(payload, list) or not payload:
return LedgerState(agent=self._agent)

latest_row = payload[0]
if not isinstance(latest_row, dict):
return LedgerState(agent=self._agent)
return LedgerState(
agent=str(latest_row.get("agent", self._agent)),
cumulative_coins=float(latest_row.get("cumulative_coins", 0.0)),
streak_pass=int(latest_row.get("streak_pass", 0)),
streak_block=int(latest_row.get("streak_block", 0)),
last_delta=int(latest_row.get("last_delta", 0) or 0),
last_verdict=latest_row.get("last_verdict"),
violation_counts={},
runs=[],
)

def _write_supabase(self, run_entry: LedgerRun, state: LedgerState) -> bool:
try:
run_response = requests.post(
f"{self._supabase_url}/rest/v1/review_runs",
headers=self._supabase_headers(prefer_representation=True),
json=run_entry.model_dump(mode="json"),
timeout=10,
)
run_response.raise_for_status()
ledger_response = requests.post(
f"{self._supabase_url}/rest/v1/reward_ledger",
headers=self._supabase_headers(prefer_representation=True),
json={
"agent": state.agent,
"session_id": run_entry.session_id,
"cumulative_coins": state.cumulative_coins,
"last_delta": state.last_delta,
"last_verdict": state.last_verdict,
"streak_pass": state.streak_pass,
"streak_block": state.streak_block,
"updated_at": datetime.now(UTC).isoformat(),
},
timeout=10,
)
ledger_response.raise_for_status()
except requests.RequestException:
return False
return True

def _supabase_headers(self, *, prefer_representation: bool = False) -> dict[str, str]:
headers = {
"apikey": self._supabase_key,
"Authorization": f"Bearer {self._supabase_key}",
"Content-Type": "application/json",
}
if prefer_representation:
headers["Prefer"] = "return=representation"
return headers

def _status_payload(self, state: LedgerState) -> dict[str, object]:
top_violations = sorted(state.violation_counts.items(), key=lambda item: (-item[1], item[0]))[:3]
return {
"coins": round(state.cumulative_coins, 2),
"streak_pass": state.streak_pass,
"streak_block": state.streak_block,
"last_verdict": state.last_verdict or "UNKNOWN",
"top_violations": top_violations,
}
Loading
Loading