From f770825975ecf6cb9389ed48f92c49ff0ad25d53 Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 22 Jul 2026 00:18:52 +0200 Subject: [PATCH] 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). --- README.md | 3 + agent_harness/brief_daily.py | 353 +++++++++++++++++++++++++++++++++++ agent_harness/cli.py | 55 ++++++ tests/test_brief_daily.py | 125 +++++++++++++ 4 files changed, 536 insertions(+) create mode 100644 agent_harness/brief_daily.py create mode 100644 tests/test_brief_daily.py diff --git a/README.md b/README.md index 6558c5e..3a4ae25 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/agent_harness/brief_daily.py b/agent_harness/brief_daily.py new file mode 100644 index 0000000..ee730df --- /dev/null +++ b/agent_harness/brief_daily.py @@ -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, + }, + ) diff --git a/agent_harness/cli.py b/agent_harness/cli.py index 0ee5f67..566a70d 100644 --- a/agent_harness/cli.py +++ b/agent_harness/cli.py @@ -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 diff --git a/tests/test_brief_daily.py b/tests/test_brief_daily.py new file mode 100644 index 0000000..c57b31d --- /dev/null +++ b/tests/test_brief_daily.py @@ -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