From 9107a0bf6d3e5c31a502bb95a37db85df9b1f968 Mon Sep 17 00:00:00 2001 From: Solomon Neas Date: Tue, 26 May 2026 17:47:03 -0400 Subject: [PATCH] feat: check memory care freshness --- CHANGELOG.md | 1 + README.md | 2 +- src/brigade/doctor.py | 77 ++++++++++++++++++++++++++++++++++--------- tests/test_doctor.py | 46 +++++++++++++++++++++++++- 4 files changed, 109 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d366082..70eb856c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Built-in `brigade doctor` bootstrap budget checks that fail hard when installed bootstrap files exceed conservative byte limits. - Built-in `brigade doctor` memory-card budget checks that fail when `memory/cards/*.md` cards become too large. - Built-in `brigade doctor` memory-index checks that fail when `MEMORY.md` links to missing `memory/cards/*.md` files. +- `brigade doctor` memory-care freshness checks for stale decay scans, plus hard failures for corrupt scan or refresh-queue JSON. - `brigade run ""`, a bounded aboyeur flow that asks one rostered orchestrator to plan assignments, dispatches worker CLIs in parallel, then asks the orchestrator to synthesize the final answer. - `.brigade/roster.toml` loading for cross-model agent rosters using the user's installed CLIs (`codex`, `claude`, or `ollama:`). Claude is optional, not required. - `brigade roster init` and `brigade roster doctor` to scaffold a Codex/Ollama starter roster and validate roster syntax plus installed CLI availability. diff --git a/README.md b/README.md index f2abe2cc..568a4fcf 100644 --- a/README.md +++ b/README.md @@ -168,7 +168,7 @@ Use `--output-dir ` to pick the artifact directory, or `--no-artifacts` fo Use `brigade work status` as the quick daily dashboard for a repo. It reports the current branch, dirty files, dogfood readiness, configured dogfood paths, latest dogfood run, and extracted next step without starting a new orchestration. Use `brigade work doctor` to check whether the repo is ready for the daily loop: dogfood config, Codex CLI, local artifact paths, handoff inbox, ignore coverage, and latest run context. Use `brigade work resume` when returning to a repo; it shows the active or latest work session, latest dogfood run, extracted next step, and the suggested command to continue. `brigade work run` is the one-command daily loop: it starts a work session, runs `brigade dogfood`, ends the session, writes a work-session Memory Handoff by default, and prints a compact recap. Pass a task to override the default next-slice review, `--title` to name the session, `--no-handoff` to skip the work handoff, or `--dogfood-handoff` to also let the underlying dogfood run write its own handoff. `brigade work start "title"` opens a local work session under `.brigade/work//`, records the starting git and dogfood context, and writes `start.md`. `brigade work note "checkpoint"` appends a timestamped note to the active session without ending it. `brigade work end --note "what happened"` closes the active session, records ending context, and writes `end.md`. Add `--handoff` to also write a Memory Handoff for the closed session; it defaults to the configured dogfood handoff inbox or `.codex/memory-handoffs`. -Memory and bootstrap readiness are part of the same operating-system health story. `brigade doctor` checks installed bootstrap files against built-in hard byte budgets so overgrown files fail before agents load truncated context. It also checks `memory/cards/*.md` budgets and verifies that `MEMORY.md` card links resolve under `memory/cards/`, so the bootstrap index cannot quietly point at missing or overgrown durable memory. Bootstrap truncation is treated as a hard failure to prevent, not a cosmetic warning; keep cards atomic and bootstrap files as slim indexes. +Memory and bootstrap readiness are part of the same operating-system health story. `brigade doctor` checks installed bootstrap files against built-in hard byte budgets so overgrown files fail before agents load truncated context. It also checks `memory/cards/*.md` budgets, verifies that `MEMORY.md` card links resolve under `memory/cards/`, and reports memory-care freshness from `memory/cards/decay/scan-latest.json`. Missing memory-care decay state is advisory for fresh installs, but corrupt scan or refresh-queue JSON fails once the loop is wired. Bootstrap truncation is treated as a hard failure to prevent, not a cosmetic warning; keep cards atomic and bootstrap files as slim indexes. Inspect local work sessions with `brigade work list`, `brigade work latest`, or `brigade work show `. Use `brigade work recap` for a compact summary of recent sessions, or add `--since YYYY-MM-DD` for a day-range recap. diff --git a/src/brigade/doctor.py b/src/brigade/doctor.py index c0ce15d5..f860c7ab 100644 --- a/src/brigade/doctor.py +++ b/src/brigade/doctor.py @@ -7,6 +7,7 @@ import shutil import subprocess import sys +from datetime import date, datetime from pathlib import Path from typing import Callable, List, Tuple @@ -29,6 +30,7 @@ "HEARTBEAT.md": 5_000, } MEMORY_CARD_BUDGET_BYTES = 8_000 +MEMORY_CARE_SCAN_STALE_DAYS = 7 from .station import DoctorContext @@ -331,15 +333,19 @@ def _check_memory_care(target: Path) -> List[CheckResult]: detail = str(scan) try: data = json.loads(scan.read_text()) - scan_date = data.get("scan_date") - counts = data.get("counts", {}) - if scan_date: - detail = f"{scan} (scan_date={scan_date}, stale={counts.get('stale', 'unknown')})" + if not isinstance(data, dict): + results.append((FAIL, "memory-care: scan-latest", f"expected JSON object: {scan}")) + else: + scan_date = data.get("scan_date") + counts = data.get("counts", {}) + if not isinstance(counts, dict): + counts = {} + if scan_date: + detail = f"{scan} (scan_date={scan_date}, stale={counts.get('stale', 'unknown')})" + results.append((OK, "memory-care: scan-latest", detail)) + results.append(_check_memory_care_scan_freshness(scan, scan_date)) except json.JSONDecodeError: - detail = f"invalid JSON: {scan}" - results.append((WARN, "memory-care: scan-latest", detail)) - else: - results.append((OK, "memory-care: scan-latest", detail)) + results.append((FAIL, "memory-care: scan-latest", f"invalid JSON: {scan}")) else: results.append((WARN, "memory-care: scan-latest", f"missing at {scan}")) @@ -347,20 +353,61 @@ def _check_memory_care(target: Path) -> List[CheckResult]: detail = str(queue) try: data = json.loads(queue.read_text()) - cards = data.get("cards", []) - if isinstance(cards, list): - detail = f"{queue} ({len(cards)} queued)" + if not isinstance(data, dict): + results.append((FAIL, "memory-care: refresh-queue", f"expected JSON object: {queue}")) + else: + cards = data.get("cards", []) + if not isinstance(cards, list): + results.append((FAIL, "memory-care: refresh-queue", f"`cards` must be a list: {queue}")) + else: + detail = f"{queue} ({len(cards)} queued)" + results.append((OK, "memory-care: refresh-queue", detail)) except json.JSONDecodeError: - detail = f"invalid JSON: {queue}" - results.append((WARN, "memory-care: refresh-queue", detail)) - else: - results.append((OK, "memory-care: refresh-queue", detail)) + results.append((FAIL, "memory-care: refresh-queue", f"invalid JSON: {queue}")) else: results.append((WARN, "memory-care: refresh-queue", f"missing at {queue}")) return results +def _check_memory_care_scan_freshness(scan: Path, scan_date: object) -> CheckResult: + if not scan_date: + return (WARN, "memory-care: scan freshness", f"scan_date missing in {scan}") + parsed = _parse_memory_care_scan_date(scan_date) + if parsed is None: + return (WARN, "memory-care: scan freshness", f"unparseable scan_date={scan_date!r} in {scan}") + age_days = (_memory_care_today() - parsed).days + if age_days < 0: + return (WARN, "memory-care: scan freshness", f"scan_date is in the future: {scan_date}") + if age_days > MEMORY_CARE_SCAN_STALE_DAYS: + return ( + WARN, + "memory-care: scan freshness", + f"last scan {age_days} days ago; run memory-care scanner", + ) + return (OK, "memory-care: scan freshness", f"last scan {age_days} days ago") + + +def _parse_memory_care_scan_date(value: object) -> date | None: + if not isinstance(value, str): + return None + text = value.strip() + if not text: + return None + try: + return datetime.fromisoformat(text.replace("Z", "+00:00")).date() + except ValueError: + pass + try: + return date.fromisoformat(text[:10]) + except ValueError: + return None + + +def _memory_care_today() -> date: + return date.today() + + def _check_publish_gate(target: Path) -> List[CheckResult]: results: List[CheckResult] = [] hook = target / "hooks" / "pre-push" diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 91d10647..768a33e7 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -1,6 +1,7 @@ """Tests for brigade doctor.""" from __future__ import annotations +from datetime import date from pathlib import Path import json @@ -92,7 +93,8 @@ def test_doctor_hermes_flags_experimental(tmp_target: Path, capsys): assert rc == 0 -def test_doctor_reports_memory_care_files(tmp_target: Path, capsys): +def test_doctor_reports_memory_care_files(tmp_target: Path, monkeypatch, capsys): + monkeypatch.setattr(doctor_mod, "_memory_care_today", lambda: date(2026, 5, 14)) install_selection( tmp_target, Selection(depth="workspace", harnesses=["claude"], owner="claude", includes=[]), @@ -109,10 +111,52 @@ def test_doctor_reports_memory_care_files(tmp_target: Path, capsys): assert rc == 0 assert "memory-care: scan-latest" in out assert "stale=2" in out + assert "memory-care: scan freshness" in out + assert "last scan 1 days ago" in out assert "memory-care: refresh-queue" in out assert "1 queued" in out +def test_doctor_warns_when_memory_care_scan_is_stale(tmp_target: Path, monkeypatch, capsys): + monkeypatch.setattr(doctor_mod, "_memory_care_today", lambda: date(2026, 5, 26)) + install_selection( + tmp_target, + Selection(depth="workspace", harnesses=["claude"], owner="claude", includes=[]), + ) + decay = tmp_target / "memory" / "cards" / "decay" + decay.mkdir(exist_ok=True) + (decay / "scan-latest.json").write_text( + json.dumps({"scan_date": "2026-05-01", "counts": {"stale": 4}}) + ) + (decay / "refresh-queue.json").write_text(json.dumps({"cards": []})) + + rc = doctor_mod.run(target=tmp_target, harness="generic") + out = capsys.readouterr().out + assert rc == 0 + assert "memory-care: scan freshness" in out + assert "last scan 25 days ago" in out + assert "run memory-care scanner" in out + + +def test_doctor_fails_when_memory_care_state_is_invalid(tmp_target: Path, capsys): + install_selection( + tmp_target, + Selection(depth="workspace", harnesses=["claude"], owner="claude", includes=[]), + ) + decay = tmp_target / "memory" / "cards" / "decay" + decay.mkdir(exist_ok=True) + (decay / "scan-latest.json").write_text("{not-json") + (decay / "refresh-queue.json").write_text(json.dumps({"cards": "not-a-list"})) + + rc = doctor_mod.run(target=tmp_target, harness="generic") + out = capsys.readouterr().out + assert rc == 1 + assert "memory-care: scan-latest" in out + assert "invalid JSON" in out + assert "memory-care: refresh-queue" in out + assert "`cards` must be a list" in out + + def test_doctor_verifies_memory_index_card_links(tmp_target: Path, capsys): install_selection( tmp_target,