rein-aharness/rein_aharness/brief_weekly.py

424 lines
14 KiB
Python

"""Weekly founder-review prep via llm-connect and deterministic markdown.
The model summarizes bounded repository evidence. Milestone movement and the
RISK-005 watch/escalation state are derived in code so they cannot be invented
by model output.
"""
from __future__ import annotations
import json
import os
import re
import subprocess
from dataclasses import dataclass, field
from datetime import date, datetime, timedelta
from pathlib import Path
from typing import Any, Callable
from zoneinfo import ZoneInfo
from rein_aharness import hub
from rein_aharness.llm_connect_client import (
LLMConnectClient,
LLMConnectError,
get_llm_connect_client,
)
_CONTEXT_FILES = (
"SuccessMilestones.md",
"DecisionQueue.md",
"RiskRegister.md",
"WORK-RECORDS.md",
)
_MAX_FILE_CHARS = 7000
_MAX_GIT_LOG = 30
class BriefWeeklyError(RuntimeError):
pass
@dataclass(frozen=True)
class WeeklySignals:
milestone_moved: bool
activity_present: bool
prior_week_milestone_free: bool
risk005_state: str
milestone_commits: tuple[str, ...] = ()
@dataclass
class BriefWeeklyResult:
ok: bool
date: str
path: str | None = None
wrote: bool = False
committed: bool = False
head_after: str = ""
reason: str = ""
model_meta: dict[str, Any] = field(default_factory=dict)
skipped_existing: bool = False
milestone_moved: bool = False
risk005_state: str = ""
def _berlin_today() -> date:
try:
return datetime.now(ZoneInfo("Europe/Berlin")).date()
except Exception:
return date.today()
def brief_path_for(repo: Path, day: date) -> Path:
return repo / "briefs" / f"{day.isoformat()}-weekly-founder-review.md"
def _git(repo: Path, *args: str) -> str:
result = subprocess.run(
["git", "-C", str(repo), *args],
capture_output=True,
text=True,
timeout=60,
)
if result.returncode != 0:
raise BriefWeeklyError(
f"git {' '.join(args)} failed: {result.stderr.strip()[:200]}"
)
return result.stdout.strip()
def _truncate(text: str, n: int) -> str:
if len(text) <= n:
return text
return text[: n - 20] + "\n…(truncated)…\n"
def _previous_weekly_review(repo: Path, day: date) -> Path | None:
candidates = sorted((repo / "briefs").glob("*-weekly-founder-review.md"))
current = brief_path_for(repo, day)
prior = [path for path in candidates if path != current and path.name[:10] < day.isoformat()]
return prior[-1] if prior else None
def derive_signals(repo: Path, day: date) -> WeeklySignals:
since = (day - timedelta(days=6)).isoformat()
milestone_log = _git(
repo,
"log",
f"--since={since} 00:00:00",
"--format=%h %ad %s",
"--date=short",
"--",
"SuccessMilestones.md",
)
milestone_commits = tuple(line for line in milestone_log.splitlines() if line.strip())
activity_log = _git(
repo,
"log",
f"--since={since} 00:00:00",
"--format=%h %ad %s",
"--date=short",
f"-{_MAX_GIT_LOG}",
)
prior = _previous_weekly_review(repo, day)
prior_text = (
prior.read_text(encoding="utf-8", errors="replace").lower() if prior else ""
)
prior_free = (
"no milestone status changed" in prior_text
or "no milestone moved" in prior_text
)
moved = bool(milestone_commits)
activity = bool(activity_log.strip())
if moved:
risk_state = "clear"
elif activity and prior_free:
risk_state = "escalate"
elif activity:
risk_state = "watch"
else:
risk_state = "quiet"
return WeeklySignals(
milestone_moved=moved,
activity_present=activity,
prior_week_milestone_free=prior_free,
risk005_state=risk_state,
milestone_commits=milestone_commits,
)
def collect_context(repo: Path, day: date, signals: WeeklySignals) -> str:
since = day - timedelta(days=6)
chunks = [
f"Review date (Europe/Berlin): {day.isoformat()}",
f"Review window: {since.isoformat()} through {day.isoformat()}",
"Deterministic signals: "
+ json.dumps(
{
"milestone_moved": signals.milestone_moved,
"activity_present": signals.activity_present,
"prior_week_milestone_free": signals.prior_week_milestone_free,
"risk005_state": signals.risk005_state,
"milestone_commits": list(signals.milestone_commits),
},
sort_keys=True,
),
]
for rel in _CONTEXT_FILES:
path = repo / rel
body = (
_truncate(path.read_text(encoding="utf-8", errors="replace"), _MAX_FILE_CHARS)
if path.is_file()
else "(missing)"
)
chunks.append(f"## {rel}\n{body}")
briefs = sorted((repo / "briefs").glob("*-daily-brief.md"))
weekly = [
path
for path in briefs
if since.isoformat() <= path.name[:10] <= day.isoformat()
]
for path in weekly:
chunks.append(
f"## Daily brief: {path.name}\n"
+ _truncate(path.read_text(encoding="utf-8", errors="replace"), 3500)
)
prior = _previous_weekly_review(repo, day)
if prior:
chunks.append(
f"## Previous weekly review: {prior.name}\n"
+ _truncate(prior.read_text(encoding="utf-8", errors="replace"), 4500)
)
else:
chunks.append("## Previous weekly review\n(none)")
chunks.append(
"## Recent git log\n"
+ _git(repo, "log", f"-{_MAX_GIT_LOG}", "--oneline", f"--since={since.isoformat()}")
)
return "\n\n".join(chunks)
def build_prompt(context: str) -> str:
return f"""Prepare the Binky weekly founder-review note from the supplied evidence.
Green/Blue lane only. Output ONLY valid JSON (no markdown fences).
Schema:
{{
"milestone_summary": "brief factual answer to whether a milestone moved",
"milestone_evidence": ["short evidence bullets"],
"founder_actions": ["prepared decisions/actions only, with ids when known"]
}}
Rules:
- Do not decide milestone_moved or RISK-005 state; code supplies those facts.
- Do not invent ids, status changes, amounts, commits, or events.
- Distinguish milestone-relevant activity from an actual milestone status change.
- Founder actions must be prepared packages, never raw questions. Maximum 5.
- Keep every field concise enough for a ~15-minute review.
## Context
{context}
"""
def parse_response(text: str) -> dict[str, Any]:
cleaned = text.strip()
if cleaned.startswith("```"):
cleaned = re.sub(r"^```(?:json)?\s*", "", cleaned)
cleaned = re.sub(r"\s*```$", "", cleaned)
try:
data = json.loads(cleaned)
except json.JSONDecodeError as exc:
match = re.search(r"\{.*\}", cleaned, re.S)
if not match:
raise BriefWeeklyError(f"LLM response is not JSON: {exc}") from exc
try:
data = json.loads(match.group(0))
except json.JSONDecodeError as nested:
raise BriefWeeklyError(f"LLM response is not JSON: {nested}") from nested
if not isinstance(data, dict):
raise BriefWeeklyError("LLM JSON root must be an object")
return data
def _bullets(value: Any, *, empty: str, limit: int = 8) -> str:
values = value if isinstance(value, list) else ([value] if value else [])
lines = [str(item).lstrip("- ").strip() for item in values if str(item).strip()]
if not lines:
return f"- {empty}"
return "\n".join(f"- {line[:277] + '...' if len(line) > 280 else line}" for line in lines[:limit])
def render_brief(day: date, data: dict[str, Any], signals: WeeklySignals) -> str:
moved = "Yes" if signals.milestone_moved else "No"
summary = str(data.get("milestone_summary") or "No evidence summary supplied.").strip()
evidence = _bullets(data.get("milestone_evidence"), empty="No milestone evidence recorded.")
actions = _bullets(data.get("founder_actions"), empty="No founder action needed this week.", limit=5)
risk_text = {
"clear": "A milestone moved in the review window; the two-week RISK-005 trigger is clear.",
"watch": "Activity occurred without milestone movement; this is week 1 of the two-week RISK-005 watch.",
"escalate": "RISK-005 escalates: activity occurred without milestone movement for two consecutive review cycles.",
"quiet": "No activity was detected; the activity-without-movement trigger does not advance.",
}[signals.risk005_state]
return (
f"# Weekly Founder Review — {day.isoformat()} (prep note)\n\n"
f"> Prepared per `OperatingRhythm.md` § Weekly review (~15 min, "
"agent-prepared, founder-consumed).\n\n"
"## Read these (in order)\n\n"
"1. The newest daily brief in `briefs/`\n"
"2. `SuccessMilestones.md`\n\n"
"## The one question: did anything move a milestone?\n\n"
f"**{moved}.** {summary}\n\n"
f"{evidence}\n\n"
"## RISK-005 watch\n\n"
f"**State: {signals.risk005_state}.** {risk_text}\n\n"
"## Pending founder action\n\n"
f"{actions}\n"
)
def run_brief_weekly(
target_repo: Path,
*,
day: date | None = None,
force: bool = False,
report_to_hub: bool = True,
commit: bool = True,
client: LLMConnectClient | None = None,
complete_fn: Callable[[str], str] | None = None,
) -> BriefWeeklyResult:
repo = target_repo.expanduser().resolve()
day = day or _berlin_today()
path = brief_path_for(repo, day)
try:
signals = derive_signals(repo, day)
except (BriefWeeklyError, OSError) as exc:
result = BriefWeeklyResult(ok=False, date=day.isoformat(), reason=str(exc)[:300])
_hub(result, report_to_hub, repo)
return result
if path.is_file() and not force:
result = BriefWeeklyResult(
ok=True,
date=day.isoformat(),
path=str(path.relative_to(repo)),
skipped_existing=True,
reason="weekly review already exists for today",
milestone_moved=signals.milestone_moved,
risk005_state=signals.risk005_state,
)
try:
result.head_after = _git(repo, "rev-parse", "HEAD")
except BriefWeeklyError:
pass
_hub(result, report_to_hub, repo)
return result
meta: dict[str, Any] = {}
try:
prompt = build_prompt(collect_context(repo, day, signals))
if complete_fn is not None:
content = complete_fn(prompt)
else:
llm = client or get_llm_connect_client()
model = os.environ.get("BRIEF_WEEKLY_MODEL", "").strip() or os.environ.get(
"BRIEF_DAILY_MODEL", ""
).strip()
content = llm.complete(
prompt,
model=model,
config={
"temperature": float(os.environ.get("BRIEF_WEEKLY_TEMPERATURE", "0.2")),
"max_tokens": int(os.environ.get("BRIEF_WEEKLY_MAX_TOKENS", "1400")),
},
)
meta = dict(llm.last_response_metadata or {})
markdown = render_brief(day, parse_response(content), signals)
except (LLMConnectError, BriefWeeklyError, OSError) as exc:
result = BriefWeeklyResult(
ok=False,
date=day.isoformat(),
reason=str(exc)[:300],
model_meta=meta,
milestone_moved=signals.milestone_moved,
risk005_state=signals.risk005_state,
)
_hub(result, report_to_hub, repo)
return result
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(markdown, encoding="utf-8")
rel = str(path.relative_to(repo))
committed = False
head_after = ""
if commit:
try:
_git(repo, "add", rel)
if _git(repo, "status", "--porcelain", rel):
_git(repo, "commit", "-m", f"Weekly founder review {day.isoformat()}: automated llm-connect rhythm")
committed = True
head_after = _git(repo, "rev-parse", "HEAD")
except BriefWeeklyError as exc:
result = BriefWeeklyResult(
ok=False,
date=day.isoformat(),
path=rel,
wrote=True,
reason=f"write ok but commit failed: {exc}",
model_meta=meta,
milestone_moved=signals.milestone_moved,
risk005_state=signals.risk005_state,
)
_hub(result, report_to_hub, repo)
return result
else:
try:
head_after = _git(repo, "rev-parse", "HEAD")
except BriefWeeklyError:
pass
result = BriefWeeklyResult(
ok=True,
date=day.isoformat(),
path=rel,
wrote=True,
committed=committed,
head_after=head_after,
model_meta=meta,
milestone_moved=signals.milestone_moved,
risk005_state=signals.risk005_state,
)
_hub(result, report_to_hub, repo)
return result
def _hub(result: BriefWeeklyResult, report_to_hub: bool, repo: Path) -> None:
if not report_to_hub:
return
event_type = "binky_weekly_review" if result.ok else "executor_run"
summary = (
f"binky weekly review {result.date}"
+ (" (already present)" if result.skipped_existing else f" wrote={result.wrote} committed={result.committed}")
if result.ok
else f"binky weekly review failed: {result.reason}"
)
hub.post_progress_event(
summary=summary,
event_type=event_type,
detail={
"repo": repo.name,
"ok": result.ok,
"date": result.date,
"path": result.path,
"wrote": result.wrote,
"committed": result.committed,
"skipped_existing": result.skipped_existing,
"milestone_moved": result.milestone_moved,
"risk005_state": result.risk005_state,
"reason": result.reason,
"model_meta": result.model_meta,
},
)