diff --git a/README.md b/README.md index 2215524..c7a5be3 100644 --- a/README.md +++ b/README.md @@ -63,8 +63,9 @@ rein-aharness mail-scan --target-repo ~/binky-control export LLM_CONNECT_URL=http://llm-connect.activity-core.svc.cluster.local:8080 rein-aharness mail-triage --target-repo ~/binky-control -# Daily brief via llm-connect (structured JSON → briefs/YYYY-MM-DD-daily-brief.md) +# Daily and weekly briefs via llm-connect (structured JSON → deterministic markdown) rein-aharness brief-daily --target-repo ~/binky-control +rein-aharness brief-weekly --target-repo ~/binky-control # Railiance packaging smoke (commit + optional push + hub; no Claude required) rein-aharness smoke --work-dir ~/work/executor-sandbox diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 606b752..34bcc9e 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -12,7 +12,7 @@ | workplan | HARNESS-WP-0002 | finished | — | workplans/HARNESS-WP-0002-rename-and-glas-harness-alignment.md | | workplan | REIN-A-0001 | finished | — | workplans/REIN-A-0001-statehub-bootstrap.md | | workplan | REIN-A-0002 | finished | — | workplans/REIN-A-0002-ops-run-claim-loop.md | -| workplan | REIN-A-0003 | proposed | — | workplans/REIN-A-0003-binky-weekly-review-approach.md | +| workplan | REIN-A-0003 | active | — | workplans/REIN-A-0003-binky-weekly-review-approach.md | | task | HARNESS-WP-0001-T01 | done | — | workplans/HARNESS-WP-0001-harness-foundation.md | | task | HARNESS-WP-0001-T02 | done | — | workplans/HARNESS-WP-0001-harness-foundation.md | | task | HARNESS-WP-0001-T03 | done | — | workplans/HARNESS-WP-0001-harness-foundation.md | @@ -33,6 +33,6 @@ | task | REIN-A-0002-T04 | done | — | workplans/REIN-A-0002-ops-run-claim-loop.md | | task | REIN-A-0002-T05 | done | — | workplans/REIN-A-0002-ops-run-claim-loop.md | | task | REIN-A-0002-T06 | done | — | workplans/REIN-A-0002-ops-run-claim-loop.md | -| task | REIN-A-0003-T01 | todo | — | workplans/REIN-A-0003-binky-weekly-review-approach.md | -| task | REIN-A-0003-T02 | wait | — | workplans/REIN-A-0003-binky-weekly-review-approach.md | -| task | REIN-A-0003-T03 | wait | — | workplans/REIN-A-0003-binky-weekly-review-approach.md | +| task | REIN-A-0003-T01 | done | — | workplans/REIN-A-0003-binky-weekly-review-approach.md | +| task | REIN-A-0003-T02 | done | — | workplans/REIN-A-0003-binky-weekly-review-approach.md | +| task | REIN-A-0003-T03 | progress | — | workplans/REIN-A-0003-binky-weekly-review-approach.md | diff --git a/docs/ops-run-claim-loop.md b/docs/ops-run-claim-loop.md index 212d0b6..6eb8ada 100644 --- a/docs/ops-run-claim-loop.md +++ b/docs/ops-run-claim-loop.md @@ -50,6 +50,7 @@ export ACTIVITY_CORE_WORKER_TOKEN=… # from actcore-runtime-secre | ----- | -------- | | labels `research-brief` / `freedom-intelligence` or fi-daily* | `fi-research-brief` | | labels `rhythm` / `binky-daily` or binky-daily* | `brief-daily` | +| labels `binky` + `weekly-review` or binky-weekly-review* | `brief-weekly` | | labels `mail-intake` | `mail-scan` then `mail-triage` | | labels `agent-session` | agentic `run` (TaskSpec) | | none | fail ops_run (no silent drop) | diff --git a/rein_aharness/approaches.py b/rein_aharness/approaches.py index 6123534..038d372 100644 --- a/rein_aharness/approaches.py +++ b/rein_aharness/approaches.py @@ -22,6 +22,7 @@ from rein_aharness.taskspec import TaskSpecError # Approach command names (stable; used in metrics + ops_run.result) APPROACH_FI_RESEARCH_BRIEF = "fi-research-brief" APPROACH_BRIEF_DAILY = "brief-daily" +APPROACH_BRIEF_WEEKLY = "brief-weekly" APPROACH_MAIL_SCAN = "mail-scan" APPROACH_MAIL_TRIAGE = "mail-triage" APPROACH_MAIL_PIPELINE = "mail-scan+triage" @@ -52,6 +53,14 @@ APPROACH_RULES: tuple[ApproachRule, ...] = ( ), hint_name=APPROACH_FI_RESEARCH_BRIEF, ), + ApproachRule( + name=APPROACH_BRIEF_WEEKLY, + labels_all=frozenset({"binky", "weekly-review"}), + blob_contains=frozenset( + {"binky-weekly-review", "binky_weekly_review", "weekly founder review"} + ), + hint_name=APPROACH_BRIEF_WEEKLY, + ), ApproachRule( name=APPROACH_BRIEF_DAILY, labels_any=frozenset({"rhythm", "binky-daily", "daily-brief"}), @@ -113,6 +122,7 @@ def select_approach(run: OpsRun) -> str: known = { APPROACH_FI_RESEARCH_BRIEF, APPROACH_BRIEF_DAILY, + APPROACH_BRIEF_WEEKLY, APPROACH_MAIL_SCAN, APPROACH_MAIL_TRIAGE, APPROACH_MAIL_PIPELINE, @@ -125,11 +135,12 @@ def select_approach(run: OpsRun) -> str: blob = _match_blob(run) for rule in APPROACH_RULES: - if rule.labels_all and not rule.labels_all.issubset(labels): - continue - if rule.labels_any and (rule.labels_any & labels): - return rule.name - if rule.blob_contains and any(s in blob for s in rule.blob_contains): + matches_all = bool(rule.labels_all) and rule.labels_all.issubset(labels) + matches_any = bool(rule.labels_any) and bool(rule.labels_any & labels) + matches_blob = bool(rule.blob_contains) and any( + substring in blob for substring in rule.blob_contains + ) + if matches_all or matches_any or matches_blob: return rule.name return APPROACH_UNMATCHED @@ -182,6 +193,8 @@ def execute_approach( return _run_fi(target, report_to_hub=report_to_hub, commit=commit) if name == APPROACH_BRIEF_DAILY: return _run_brief_daily(target, report_to_hub=report_to_hub, commit=commit) + if name == APPROACH_BRIEF_WEEKLY: + return _run_brief_weekly(target, report_to_hub=report_to_hub, commit=commit) if name == APPROACH_MAIL_SCAN: return _run_mail_scan(target, report_to_hub=report_to_hub) if name == APPROACH_MAIL_TRIAGE: @@ -261,6 +274,33 @@ def _run_brief_daily( ) +def _run_brief_weekly( + target: Path, *, report_to_hub: bool, commit: bool +) -> ApproachResult: + from rein_aharness.brief_weekly import run_brief_weekly + + r = run_brief_weekly( + target_repo=target, + report_to_hub=report_to_hub, + commit=commit, + ) + return ApproachResult( + ok=r.ok, + approach=APPROACH_BRIEF_WEEKLY, + result={ + "date": r.date, + "path": r.path, + "wrote": r.wrote, + "committed": r.committed, + "skipped_existing": r.skipped_existing, + "milestone_moved": r.milestone_moved, + "risk005_state": r.risk005_state, + "head_after": r.head_after, + "target_repo": "binky-control", + }, + reason=r.reason, + reopen=not r.ok and not r.skipped_existing, + ) def _run_mail_scan(target: Path, *, report_to_hub: bool) -> ApproachResult: from rein_aharness.mailscan import run_mail_scan @@ -357,5 +397,3 @@ def _run_agent_session( reason=r.reason, reopen=not r.ok, ) - - diff --git a/rein_aharness/brief_weekly.py b/rein_aharness/brief_weekly.py new file mode 100644 index 0000000..75725ef --- /dev/null +++ b/rein_aharness/brief_weekly.py @@ -0,0 +1,424 @@ +"""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, + }, + ) diff --git a/rein_aharness/cli.py b/rein_aharness/cli.py index 256f3e3..e5f75d5 100644 --- a/rein_aharness/cli.py +++ b/rein_aharness/cli.py @@ -364,6 +364,28 @@ def main(argv: list[str] | None = None) -> int: help="Write brief file but do not git commit", ) + weekly = sub.add_parser( + "brief-weekly", + help="Write weekly founder-review prep via llm-connect (no coding agent)", + ) + weekly.add_argument("--target-repo", required=True) + weekly.add_argument( + "--date", + default=None, + help="Review date YYYY-MM-DD (default: today Europe/Berlin)", + ) + weekly.add_argument( + "--force", + action="store_true", + help="Overwrite if today's weekly review already exists", + ) + weekly.add_argument("--no-hub", action="store_true", help="Skip hub reporting") + weekly.add_argument( + "--no-commit", + action="store_true", + help="Write review file but do not git commit", + ) + fi_brief = sub.add_parser( "fi-research-brief", help=( @@ -599,6 +621,39 @@ def main(argv: list[str] | None = None) -> int: ) return 0 if brief_result.ok else 1 + if args.command == "brief-weekly": + from datetime import date as date_cls + + from rein_aharness.brief_weekly import run_brief_weekly + + day = date_cls.fromisoformat(args.date) if args.date else None + result = run_brief_weekly( + 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": 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, + "head_after": result.head_after, + "reason": result.reason, + "model_meta": result.model_meta, + }, + indent=2, + ) + ) + return 0 if result.ok else 1 + if args.command == "fi-research-brief": from datetime import date as date_cls diff --git a/tests/test_approaches.py b/tests/test_approaches.py index 7f7b181..d1cb12e 100644 --- a/tests/test_approaches.py +++ b/tests/test_approaches.py @@ -5,6 +5,7 @@ from __future__ import annotations from rein_aharness.approaches import ( APPROACH_AGENT_SESSION, APPROACH_BRIEF_DAILY, + APPROACH_BRIEF_WEEKLY, APPROACH_FI_RESEARCH_BRIEF, APPROACH_MAIL_PIPELINE, APPROACH_UNMATCHED, @@ -56,6 +57,28 @@ def test_select_binky_rhythm() -> None: ) +def test_select_binky_weekly_review_requires_binky_and_weekly_label() -> None: + assert ( + select_approach( + _run(labels=["binky", "weekly-review", "automated"]) + ) + == APPROACH_BRIEF_WEEKLY + ) + + +def test_select_binky_weekly_review_by_definition() -> None: + assert ( + select_approach( + _run(activity_definition_id="binky-weekly-review-prep") + ) + == APPROACH_BRIEF_WEEKLY + ) + + +def test_weekly_review_label_alone_is_unmatched() -> None: + assert select_approach(_run(labels=["weekly-review"])) == APPROACH_UNMATCHED + + def test_select_mail_intake() -> None: assert ( select_approach(_run(labels=["mail-intake", "automated"])) diff --git a/tests/test_brief_weekly.py b/tests/test_brief_weekly.py new file mode 100644 index 0000000..b8ff099 --- /dev/null +++ b/tests/test_brief_weekly.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +import json +import os +import subprocess +from datetime import date +from pathlib import Path + +from rein_aharness import brief_weekly + + +def _git(repo: Path, *args: str, commit_date: str | None = None) -> None: + env = None + if commit_date: + env = dict(os.environ) + env["GIT_AUTHOR_DATE"] = commit_date + env["GIT_COMMITTER_DATE"] = commit_date + subprocess.run( + ["git", *args], cwd=repo, check=True, capture_output=True, env=env + ) + + +def _repo(tmp_path: Path) -> Path: + repo = tmp_path / "binky-control" + repo.mkdir() + _git(repo, "init") + _git(repo, "config", "user.email", "test@example.com") + _git(repo, "config", "user.name", "test") + for name, body in { + "SuccessMilestones.md": "# Milestones\n\nS1: started\n", + "DecisionQueue.md": "# Decisions\n\nDEC-1 prepared\n", + "RiskRegister.md": "# Risks\n\nRISK-005 open\n", + "WORK-RECORDS.md": "# Records\n", + }.items(): + (repo / name).write_text(body, encoding="utf-8") + (repo / "briefs").mkdir() + (repo / "briefs/2026-08-07-daily-brief.md").write_text( + "# Daily Brief\n\nProgress happened.\n", encoding="utf-8" + ) + _git(repo, "add", ".") + _git( + repo, + "commit", + "-m", + "initial evidence", + commit_date="2026-07-01T12:00:00+00:00", + ) + return repo + + +def test_render_uses_deterministic_risk_state() -> None: + signals = brief_weekly.WeeklySignals( + milestone_moved=False, + activity_present=True, + prior_week_milestone_free=True, + risk005_state="escalate", + ) + text = brief_weekly.render_brief( + date(2026, 8, 7), + { + "milestone_summary": "Work occurred but no status changed.", + "milestone_evidence": ["S1-related work landed"], + "founder_actions": ["Review DEC-1"], + "risk005_state": "clear", + }, + signals, + ) + assert "**No.**" in text + assert "**State: escalate.**" in text + assert "Review DEC-1" in text + assert "State: clear" not in text + + +def test_derive_signals_escalates_after_prior_free_week(tmp_path: Path) -> None: + repo = _repo(tmp_path) + (repo / "briefs/2026-07-31-weekly-founder-review.md").write_text( + "# Weekly Founder Review\n\nNo milestone status changed this week.\n", + encoding="utf-8", + ) + _git(repo, "add", ".") + _git(repo, "commit", "-m", "prior weekly review") + signals = brief_weekly.derive_signals(repo, date.today()) + assert not signals.milestone_moved + assert signals.activity_present + assert signals.prior_week_milestone_free + assert signals.risk005_state == "escalate" + + +def test_run_weekly_mock_writes_commits_and_reports(tmp_path: Path, monkeypatch) -> None: + repo = _repo(tmp_path) + events: list[dict] = [] + monkeypatch.setattr( + brief_weekly.hub, + "post_progress_event", + lambda **kwargs: events.append(kwargs) or True, + ) + + def fake(prompt: str) -> str: + assert "SuccessMilestones.md" in prompt + assert "Deterministic signals" in prompt + return json.dumps( + { + "milestone_summary": "No milestone status changed.", + "milestone_evidence": ["Daily work was recorded."], + "founder_actions": ["Review DEC-1"], + } + ) + + result = brief_weekly.run_brief_weekly( + repo, + day=date(2026, 8, 8), + force=True, + complete_fn=fake, + ) + assert result.ok + assert result.wrote + assert result.committed + assert result.risk005_state == "quiet" + assert (repo / "briefs/2026-08-08-weekly-founder-review.md").is_file() + assert events[-1]["event_type"] == "binky_weekly_review" + assert events[-1]["detail"]["risk005_state"] == result.risk005_state + + +def test_skip_existing_does_not_call_model(tmp_path: Path, monkeypatch) -> None: + repo = _repo(tmp_path) + monkeypatch.setattr(brief_weekly.hub, "post_progress_event", lambda **kw: True) + day = date(2026, 8, 8) + path = brief_weekly.brief_path_for(repo, day) + path.write_text("# Weekly Founder Review\n", encoding="utf-8") + _git(repo, "add", ".") + _git(repo, "commit", "-m", "weekly review") + + result = brief_weekly.run_brief_weekly( + repo, + day=day, + complete_fn=lambda prompt: (_ for _ in ()).throw(AssertionError(prompt)), + ) + assert result.ok + assert result.skipped_existing + + +def test_parse_response_accepts_json_fence() -> None: + data = brief_weekly.parse_response( + '```json\n{"milestone_summary":"none","milestone_evidence":[],"founder_actions":[]}\n```' + ) + assert data["milestone_summary"] == "none"