rein-aharness/rein_aharness/brief_daily.py
tegwick f6930ad115 Rename package, CLI, and deploy artifacts to rein-aharness (HARNESS-WP-0002-T02)
agent_harness -> rein_aharness (package + all imports), CLI command
agent-harness -> rein-aharness, Docker image tag, k8s namespace/labels/
names, Makefile targets, deploy script env var/paths. In-repo identity
strings (hub event source, metrics harness field, default assignee,
argparse prog name, commit author identity) updated to match.

Historical documents left untouched on purpose: docs/adr/ADR-001-agent-harness-architecture.md,
docs/architecture.md (dated v0.1 snapshot), workplans/HARNESS-WP-0001
(completed under the old name), and the SSH host alias
"forgejo-agent-harness" (external ~/.ssh/config entry, not owned here).

Verified: 47/47 tests pass, CLI runs correctly from a fresh venv,
`make image` builds and the resulting container runs correctly.

deploy/README.md gained an explicit rename cutover checklist for what
this session cannot safely do unattended -- moving the host-side
secrets dir and checkout on railiance01, and not deleting the old k8s
namespace until the new one is confirmed working. The actual live
cutover (running that checklist against the real Railiance deployment)
is not attempted here -- real production surgery on binky-control's
live automation, needs the operator present.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 14:22:18 +02:00

353 lines
11 KiB
Python

"""Daily brief via llm-connect (structured JSON + deterministic markdown).
Server path for Binky daily rhythm — no Claude Code / host coding agent.
Format: OperatingRhythm.md (Decide now / Progress / Risks / Best next hour).
"""
from __future__ import annotations
import json
import os
import re
import subprocess
from dataclasses import dataclass, field
from datetime import date, datetime, timezone
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 = (
"DecisionQueue.md",
"OfficeHourQueue.md",
"AutopilotWorkQueue.md",
"RiskRegister.md",
"SuccessMilestones.md",
"WORK-RECORDS.md",
)
_MAX_FILE_CHARS = 6000
_MAX_GIT_LOG = 15
class BriefDailyError(RuntimeError):
pass
@dataclass
class BriefDailyResult:
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
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()}-daily-brief.md"
def collect_context(repo: Path, day: date) -> str:
chunks: list[str] = [f"Brief date (Europe/Berlin): {day.isoformat()}\n"]
for rel in _CONTEXT_FILES:
path = repo / rel
if not path.is_file():
chunks.append(f"## {rel}\n(missing)\n")
continue
text = path.read_text(encoding="utf-8", errors="replace")
chunks.append(f"## {rel}\n{_truncate(text, _MAX_FILE_CHARS)}\n")
# Recent briefs (titles only + last brief body capped)
briefs_dir = repo / "briefs"
if briefs_dir.is_dir():
briefs = sorted(briefs_dir.glob("*-daily-brief.md"))
names = [p.name for p in briefs[-5:]]
chunks.append(f"## Recent daily briefs\n{names}\n")
if briefs:
last = briefs[-1]
if last.name != f"{day.isoformat()}-daily-brief.md":
chunks.append(
f"## Previous brief ({last.name})\n"
f"{_truncate(last.read_text(encoding='utf-8', errors='replace'), 2500)}\n"
)
try:
log = _git(repo, "log", f"-{_MAX_GIT_LOG}", "--oneline")
chunks.append(f"## Recent git log\n{log}\n")
except BriefDailyError:
chunks.append("## Recent git log\n(unavailable)\n")
return "\n".join(chunks)
def build_prompt(context: str, day: date) -> str:
return f"""You write the Binky Hedgehog / Operational Knowledge company **daily brief**.
Green/Blue lane only. Output ONLY valid JSON (no markdown fences).
Schema:
{{
"decide_now": ["0-3 bullets; prepared decisions only, with ids if known"],
"progress": ["what moved; milestone-relevant only"],
"risks": ["RiskRegister changes only; empty list if none"],
"best_next_hour": "single highest-leverage founder action, or 'no founder action needed today'"
}}
Rules:
- Decide now: only prepared decision packages, never raw questions. Max 3.
- Progress: short bullets of real movement (workplans, pulls, cutovers).
- Risks: only changes; do not restate the whole register.
- Best next hour: ONE item (or explicit no action needed).
- Be factual from context; do not invent ids, amounts, or events.
- Prefer "no founder action needed today" when nothing is blocking.
- Keep total brief under one screen (~12 short bullets total).
## Context
{context}
"""
def parse_brief_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:
m = re.search(r"\{.*\}", cleaned, re.S)
if not m:
raise BriefDailyError(f"LLM response is not JSON: {exc}") from exc
try:
data = json.loads(m.group(0))
except json.JSONDecodeError as exc2:
raise BriefDailyError(f"LLM response is not JSON: {exc2}") from exc2
if not isinstance(data, dict):
raise BriefDailyError("LLM JSON root must be an object")
return data
def render_brief(day: date, data: dict[str, Any]) -> str:
decide = _bullet_list(data.get("decide_now"), empty="(none)")
progress = _bullet_list(data.get("progress"), empty="(none recorded)")
risks = _bullet_list(data.get("risks"), empty="No RiskRegister changes today.")
best = data.get("best_next_hour") or "no founder action needed today"
best = str(best).strip().replace("\n", " ")
if not best:
best = "no founder action needed today"
return (
f"# Daily Brief — {day.isoformat()}\n"
f"\n"
f"## Decide now\n"
f"{decide}\n"
f"\n"
f"## Progress\n"
f"{progress}\n"
f"\n"
f"## Risks\n"
f"{risks}\n"
f"\n"
f"## Best next hour\n"
f"{best}\n"
)
def _bullet_list(value: Any, *, empty: str) -> str:
if not value:
return f"- {empty}"
if isinstance(value, str):
lines = [value]
elif isinstance(value, list):
lines = [str(x).strip() for x in value if str(x).strip()]
else:
lines = [str(value)]
if not lines:
return f"- {empty}"
out = []
for line in lines[:8]:
line = line.lstrip("- ").strip()
if len(line) > 280:
line = line[:277] + "..."
out.append(f"- {line}")
return "\n".join(out)
def _truncate(text: str, n: int) -> str:
if len(text) <= n:
return text
return text[: n - 20] + "\n…(truncated)…\n"
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 BriefDailyError(
f"git {' '.join(args)} failed: {result.stderr.strip()[:200]}"
)
return result.stdout.strip()
def run_brief_daily(
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,
) -> BriefDailyResult:
repo = target_repo.expanduser().resolve()
day = day or _berlin_today()
path = brief_path_for(repo, day)
if path.is_file() and not force:
result = BriefDailyResult(
ok=True,
date=day.isoformat(),
path=str(path.relative_to(repo)),
skipped_existing=True,
reason="brief already exists for today",
)
try:
result.head_after = _git(repo, "rev-parse", "HEAD")
except BriefDailyError:
pass
_hub(result, report_to_hub, repo)
return result
context = collect_context(repo, day)
prompt = build_prompt(context, day)
meta: dict[str, Any] = {}
try:
if complete_fn is not None:
content = complete_fn(prompt)
else:
llm = client or get_llm_connect_client()
model = os.environ.get("BRIEF_DAILY_MODEL", "").strip() or os.environ.get(
"MAIL_TRIAGE_MODEL", ""
).strip()
content = llm.complete(
prompt,
model=model,
config={
"temperature": float(os.environ.get("BRIEF_DAILY_TEMPERATURE", "0.2")),
"max_tokens": int(os.environ.get("BRIEF_DAILY_MAX_TOKENS", "1200")),
},
)
meta = dict(llm.last_response_metadata or {})
data = parse_brief_response(content)
markdown = render_brief(day, data)
except (LLMConnectError, BriefDailyError, OSError) as exc:
result = BriefDailyResult(
ok=False,
date=day.isoformat(),
reason=str(exc)[:300],
model_meta=meta,
)
_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)
status = _git(repo, "status", "--porcelain", rel)
if status.strip():
_git(
repo,
"commit",
"-m",
f"Daily brief {day.isoformat()}: automated llm-connect rhythm",
)
committed = True
head_after = _git(repo, "rev-parse", "HEAD")
except BriefDailyError as exc:
result = BriefDailyResult(
ok=False,
date=day.isoformat(),
path=rel,
wrote=True,
reason=f"write ok but commit failed: {exc}",
model_meta=meta,
)
_hub(result, report_to_hub, repo)
return result
else:
try:
head_after = _git(repo, "rev-parse", "HEAD")
except BriefDailyError:
pass
result = BriefDailyResult(
ok=True,
date=day.isoformat(),
path=rel,
wrote=True,
committed=committed,
head_after=head_after,
model_meta=meta,
)
_hub(result, report_to_hub, repo)
return result
def _hub(result: BriefDailyResult, report_to_hub: bool, repo: Path) -> None:
if not report_to_hub:
return
# Idempotence: only successful brief writes/skips use binky_daily_brief
if result.ok:
event_type = "binky_daily_brief"
summary = (
f"binky daily brief {result.date}"
+ (
" (already present)"
if result.skipped_existing
else f" wrote={result.wrote} committed={result.committed}"
)
)
else:
event_type = "executor_run"
summary = f"binky daily brief 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,
"reason": result.reason,
"model_meta": result.model_meta,
},
)