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>
353 lines
11 KiB
Python
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,
|
|
},
|
|
)
|