diff --git a/CHANGELOG.md b/CHANGELOG.md index d02eed26..9d366082 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - 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 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. diff --git a/README.md b/README.md index bbfb42fa..f2abe2cc 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 verifies that `MEMORY.md` card links resolve under `memory/cards/`, so the bootstrap index cannot quietly point at missing durable memory. Bootstrap truncation is treated as a hard failure to prevent, not a cosmetic warning; move durable detail into `memory/cards/` and keep 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 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. 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 33c37afc..c0ce15d5 100644 --- a/src/brigade/doctor.py +++ b/src/brigade/doctor.py @@ -28,6 +28,7 @@ "IDENTITY.md": 4_000, "HEARTBEAT.md": 5_000, } +MEMORY_CARD_BUDGET_BYTES = 8_000 from .station import DoctorContext @@ -65,6 +66,7 @@ def core_station_checks(ctx: DoctorContext) -> List[CheckResult]: def memory_station_checks(ctx: DoctorContext) -> List[CheckResult]: checks: List[CheckResult] = [] checks.extend(_check_handoff_inboxes(ctx.target, ctx.selection, ctx.harnesses)) + checks.extend(_check_memory_cards(ctx.target)) checks.extend(_check_memory_index(ctx.target)) checks.extend(_check_memory_care(ctx.target)) return checks @@ -242,6 +244,51 @@ def _check_memory_index(target: Path) -> List[CheckResult]: return [(OK, "memory-index: card links", f"{len(linked_cards)} verified")] +def _check_memory_cards(target: Path) -> List[CheckResult]: + cards = target / "memory" / "cards" + if not cards.is_dir(): + return [] + + results: List[CheckResult] = [] + oversized: list[str] = [] + empty: list[str] = [] + for path in sorted(cards.rglob("*.md")): + if not path.is_file(): + continue + rel = path.relative_to(target) + try: + size = path.stat().st_size + except OSError as exc: + results.append((FAIL, f"memory-card: {rel}", f"unreadable: {exc}")) + continue + if size == 0: + empty.append(str(rel)) + if size > MEMORY_CARD_BUDGET_BYTES: + oversized.append(f"{rel} ({size}/{MEMORY_CARD_BUDGET_BYTES} bytes)") + + if empty: + preview = ", ".join(empty[:5]) + if len(empty) > 5: + preview += f", ... {len(empty) - 5} more" + results.append((WARN, "memory-card: empty", f"{len(empty)} empty card{'s' if len(empty) != 1 else ''}: {preview}")) + + if oversized: + preview = ", ".join(oversized[:5]) + if len(oversized) > 5: + preview += f", ... {len(oversized) - 5} more" + results.append( + ( + FAIL, + "memory-card: budget", + f"{len(oversized)} over hard limit; split cards into atomic topics: {preview}", + ) + ) + else: + count = len([path for path in cards.rglob("*.md") if path.is_file()]) + results.append((OK, "memory-card: budget", f"{count} card{'s' if count != 1 else ''} <= {MEMORY_CARD_BUDGET_BYTES} bytes")) + return results + + def _check_orphan_inboxes( target: Path, selected_harnesses: List[str] ) -> List[CheckResult]: diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 88e6e8f8..91d10647 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -142,6 +142,38 @@ def test_doctor_fails_broken_memory_index_card_link(tmp_target: Path, capsys): assert "memory/cards/missing-card.md" in out +def test_doctor_fails_when_memory_card_exceeds_budget(tmp_target: Path, capsys): + install_selection( + tmp_target, + Selection(depth="workspace", harnesses=["claude"], owner="claude", includes=[]), + ) + limit = doctor_mod.MEMORY_CARD_BUDGET_BYTES + oversized = tmp_target / "memory" / "cards" / "oversized.md" + oversized.write_text("x" * (limit + 1)) + + rc = doctor_mod.run(target=tmp_target, harness="generic") + out = capsys.readouterr().out + assert rc == 1 + assert "memory-card: budget" in out + assert "over hard limit" in out + assert "memory/cards/oversized.md" in out + + +def test_doctor_warns_when_memory_card_is_empty(tmp_target: Path, capsys): + install_selection( + tmp_target, + Selection(depth="workspace", harnesses=["claude"], owner="claude", includes=[]), + ) + empty = tmp_target / "memory" / "cards" / "empty.md" + empty.write_text("") + + rc = doctor_mod.run(target=tmp_target, harness="generic") + out = capsys.readouterr().out + assert rc == 0 + assert "memory-card: empty" in out + assert "memory/cards/empty.md" in out + + def test_doctor_openclaw_reports_cron_memory_jobs(tmp_target: Path, monkeypatch, capsys): install_selection( tmp_target,