feat: brief-daily via llm-connect (structured daily brief)

Generate OperatingRhythm-format briefs with OpenRouter-backed llm-connect
JSON + deterministic markdown write/commit. Server path for Binky daily
rhythm without host Claude (BINKY-WP-0006-T05).
This commit is contained in:
tegwick 2026-07-22 00:18:52 +02:00
parent 7520a53831
commit f770825975
4 changed files with 536 additions and 0 deletions

View file

@ -37,6 +37,9 @@ agent-harness mail-scan --target-repo ~/binky-control
export LLM_CONNECT_URL=http://llm-connect.activity-core.svc.cluster.local:8080
agent-harness mail-triage --target-repo ~/binky-control
# Daily brief via llm-connect (structured JSON → briefs/YYYY-MM-DD-daily-brief.md)
agent-harness brief-daily --target-repo ~/binky-control
# Railiance packaging smoke (commit + optional push + hub; no Claude required)
agent-harness smoke --work-dir ~/work/executor-sandbox
```

View file

@ -0,0 +1,353 @@
"""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 agent_harness import hub
from agent_harness.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,
},
)

View file

@ -220,6 +220,28 @@ def main(argv: list[str] | None = None) -> int:
help="Apply log updates but do not git commit",
)
brief = sub.add_parser(
"brief-daily",
help="Write daily brief via llm-connect (JSON + markdown apply; no Claude)",
)
brief.add_argument("--target-repo", required=True)
brief.add_argument(
"--date",
default=None,
help="Brief date YYYY-MM-DD (default: today Europe/Berlin)",
)
brief.add_argument(
"--force",
action="store_true",
help="Overwrite if today's brief already exists",
)
brief.add_argument("--no-hub", action="store_true", help="Skip hub reporting")
brief.add_argument(
"--no-commit",
action="store_true",
help="Write brief file but do not git commit",
)
validate = sub.add_parser(
"validate",
help="Validate instance manifest (.kaizen/schedule.yml + harness fields)",
@ -352,6 +374,39 @@ def main(argv: list[str] | None = None) -> int:
)
return 0 if triage_result.ok else 1
if args.command == "brief-daily":
from datetime import date as date_cls
from agent_harness.brief_daily import run_brief_daily
day = None
if args.date:
day = date_cls.fromisoformat(args.date)
brief_result = run_brief_daily(
target_repo=Path(args.target_repo).expanduser(),
day=day,
force=bool(args.force),
report_to_hub=not args.no_hub,
commit=not args.no_commit,
)
print(
json.dumps(
{
"ok": brief_result.ok,
"date": brief_result.date,
"path": brief_result.path,
"wrote": brief_result.wrote,
"committed": brief_result.committed,
"skipped_existing": brief_result.skipped_existing,
"head_after": brief_result.head_after,
"reason": brief_result.reason,
"model_meta": brief_result.model_meta,
},
indent=2,
)
)
return 0 if brief_result.ok else 1
if args.command == "mail-scan":
from agent_harness.mailscan import run_mail_scan

125
tests/test_brief_daily.py Normal file
View file

@ -0,0 +1,125 @@
from __future__ import annotations
import json
import subprocess
from datetime import date
from pathlib import Path
from agent_harness import brief_daily
def _git_repo(tmp_path: Path) -> Path:
repo = tmp_path / "binky-control"
repo.mkdir()
subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True)
subprocess.run(
["git", "config", "user.email", "test@example.com"],
cwd=repo,
check=True,
capture_output=True,
)
subprocess.run(
["git", "config", "user.name", "test"],
cwd=repo,
check=True,
capture_output=True,
)
(repo / "DecisionQueue.md").write_text(
"# DecisionQueue\n\n## Open\n\n### DEC-2026-999\nstatus: prepared\n",
encoding="utf-8",
)
(repo / "OfficeHourQueue.md").write_text("# OH\n", encoding="utf-8")
(repo / "AutopilotWorkQueue.md").write_text("# AWQ\n", encoding="utf-8")
(repo / "RiskRegister.md").write_text("# Risks\n", encoding="utf-8")
(repo / "briefs").mkdir()
subprocess.run(["git", "add", "."], cwd=repo, check=True, capture_output=True)
subprocess.run(
["git", "commit", "-m", "init"],
cwd=repo,
check=True,
capture_output=True,
)
return repo
def test_render_brief_format() -> None:
md = brief_daily.render_brief(
date(2026, 7, 22),
{
"decide_now": ["DEC-1 approve cutover"],
"progress": ["WP-0005 finished"],
"risks": [],
"best_next_hour": "Approve DEC-1",
},
)
assert md.startswith("# Daily Brief — 2026-07-22\n")
assert "## Decide now" in md
assert "## Progress" in md
assert "## Risks" in md
assert "## Best next hour" in md
assert "No RiskRegister changes" in md
assert "Approve DEC-1" in md
def test_parse_brief_response_fences() -> None:
data = brief_daily.parse_brief_response(
'```json\n{"decide_now":[],"progress":["x"],"risks":[],'
'"best_next_hour":"none"}\n```'
)
assert data["progress"] == ["x"]
def test_run_brief_daily_mock(tmp_path: Path, monkeypatch) -> None:
repo = _git_repo(tmp_path)
monkeypatch.setattr(brief_daily.hub, "post_progress_event", lambda **kw: True)
day = date(2026, 7, 22)
def fake(prompt: str) -> str:
assert "DecisionQueue" in prompt
return json.dumps(
{
"decide_now": ["DEC-2026-999 go"],
"progress": ["scaffold landed"],
"risks": [],
"best_next_hour": "no founder action needed today",
}
)
result = brief_daily.run_brief_daily(
repo, day=day, complete_fn=fake, force=True
)
assert result.ok
assert result.wrote
assert result.committed
path = repo / "briefs" / "2026-07-22-daily-brief.md"
assert path.is_file()
text = path.read_text(encoding="utf-8")
assert "DEC-2026-999" in text
assert "scaffold landed" in text
def test_skip_existing_brief(tmp_path: Path, monkeypatch) -> None:
repo = _git_repo(tmp_path)
monkeypatch.setattr(brief_daily.hub, "post_progress_event", lambda **kw: True)
day = date(2026, 7, 22)
path = brief_daily.brief_path_for(repo, day)
path.write_text("# Daily Brief — 2026-07-22\n\nalready\n", encoding="utf-8")
subprocess.run(["git", "add", "."], cwd=repo, check=True, capture_output=True)
subprocess.run(
["git", "commit", "-m", "brief"],
cwd=repo,
check=True,
capture_output=True,
)
called = {"n": 0}
def boom(prompt: str) -> str:
called["n"] += 1
raise AssertionError("should not call LLM")
result = brief_daily.run_brief_daily(
repo, day=day, complete_fn=boom, force=False
)
assert result.ok
assert result.skipped_existing
assert called["n"] == 0