diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a37ae3b..f4a88b0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `brigade work next` to resolve the next daily task without inspecting artifacts, plus `brigade work run` now uses the latest extracted next step when no task is passed. - `brigade work next --json` to expose the resolved daily task, active session, dogfood snapshot, and suggested command to wrappers. - `brigade work bootstrap` to initialize and verify the dogfood-backed daily work loop in one command. +- `brigade work brief` and `brigade work brief --json` as a start-of-day entrypoint with git state, latest sessions, latest dogfood run, resolved next task, and suggested command. - `brigade work note` to append timestamped checkpoints to the active work session without ending it. - `brigade work doctor` to check dogfood config, Codex availability, local artifact paths, handoff inbox, ignore coverage, and latest run context for the daily work loop. - Workspace installs now include `.brigade/memory-care.example.json` as a scanner wiring contract for memory-care decay output. @@ -53,6 +54,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Dogfood handoff defaults now use `.codex/memory-handoffs/` for new Codex-driven local configs while preserving explicit configured inbox paths such as `.claude/memory-handoffs/`. - Bootstrap truncation is now treated as a hard doctor failure to prevent by moving durable detail into memory cards before agents load context. - Dogfood runs now default to a 600 second per-agent timeout for practical daily repo reviews. +- Dogfood next-step extraction now handles markdown `## Next` sections and can fall back to `summary.md` when `final.txt` does not contain a next-step label. - The managed gitignore block now treats `.brigade/dogfood.toml` and `.brigade/runs/` as local state. - Live smoke docs now keep Codex agent execution in a trusted repo cwd while writing temporary roster, artifacts, and handoff output under `/tmp`. - Handoff write failures now preserve final run artifacts, print the final answer, return nonzero, and mark `run.json` as `handoff-failed`. diff --git a/README.md b/README.md index 8a511599..4ae23b17 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,8 @@ brigade work bootstrap brigade work status brigade work doctor brigade work resume +brigade work brief +brigade work brief --json brigade work next brigade work next --json brigade work run @@ -171,7 +173,7 @@ CLI runs write artifacts by default under `.brigade/runs/` below `--cwd`; do Use `--output-dir ` to pick the artifact directory, or `--no-artifacts` for a throwaway run. -Use `brigade work bootstrap` once in a repo to initialize the daily loop: it writes or verifies `.brigade/dogfood.toml`, creates local run and work artifact directories, creates the configured handoff inbox, updates the managed `.gitignore` block, and reports whether the repo is ready to run. 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. Use `brigade work next` when you only need the task Brigade would run next, or `brigade work next --json` for wrappers that want the active session, dogfood snapshot, resolved task, and suggested command as structured output. `brigade work run` is the one-command daily loop: it starts a work session, uses the latest extracted dogfood next step when no task is provided, 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 resolved next step, `--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`. +Use `brigade work bootstrap` once in a repo to initialize the daily loop: it writes or verifies `.brigade/dogfood.toml`, creates local run and work artifact directories, creates the configured handoff inbox, updates the managed `.gitignore` block, and reports whether the repo is ready to run. Use `brigade work brief` as the normal start-of-day command. It shows branch and dirty-file state, active and latest work sessions, latest dogfood run, extracted next task, recent sessions, and the command to continue; add `--json` for wrappers. 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. Use `brigade work next` when you only need the task Brigade would run next, or `brigade work next --json` for wrappers that want the active session, dogfood snapshot, resolved task, and suggested command as structured output. `brigade work run` is the one-command daily loop: it starts a work session, uses the latest extracted dogfood next step when no task is provided, 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 resolved next step, `--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, verifies that `MEMORY.md` card links resolve under `memory/cards/`, and reports memory-care freshness from `memory/cards/decay/scan-latest.json`. Workspace installs include `.brigade/memory-care.example.json` as a scanner wiring contract for whatever scheduler or memory owner produces the decay files. 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. diff --git a/src/brigade/cli.py b/src/brigade/cli.py index e6a87d63..bf0c592b 100644 --- a/src/brigade/cli.py +++ b/src/brigade/cli.py @@ -132,6 +132,10 @@ def _build_parser() -> argparse.ArgumentParser: p_work_bootstrap.add_argument("--no-gitignore", action="store_true", help="Do not update the target .gitignore.") p_work_resume = work_sub.add_parser("resume", help="Show the current work handoff point and next command.") p_work_resume.add_argument("--target", "-t", type=Path, default=Path("."), help="Repo or workspace to inspect.") + p_work_brief = work_sub.add_parser("brief", help="Show the daily work brief and suggested next command.") + p_work_brief.add_argument("--target", "-t", type=Path, default=Path("."), help="Repo or workspace to inspect.") + p_work_brief.add_argument("--limit", type=int, default=3, help="Maximum recent sessions to include.") + p_work_brief.add_argument("--json", action="store_true", help="Print machine-readable JSON.") p_work_next = work_sub.add_parser("next", help="Show the next daily work task and suggested command.") p_work_next.add_argument("--target", "-t", type=Path, default=Path("."), help="Repo or workspace to inspect.") p_work_next.add_argument("--json", action="store_true", help="Print machine-readable JSON.") @@ -472,6 +476,8 @@ def main(argv=None) -> int: ) if args.work_command == "resume": return work_cmd.resume(target=args.target) + if args.work_command == "brief": + return work_cmd.brief(target=args.target, limit=args.limit, json_output=args.json) if args.work_command == "next": return work_cmd.next(target=args.target, json_output=args.json) if args.work_command == "list": diff --git a/src/brigade/dogfood_cmd.py b/src/brigade/dogfood_cmd.py index 2672e37f..657f6ce4 100644 --- a/src/brigade/dogfood_cmd.py +++ b/src/brigade/dogfood_cmd.py @@ -3,6 +3,7 @@ import ast import json +import re import shutil import subprocess import sys @@ -224,31 +225,67 @@ def _read_final(run_dir: Path) -> str: return "" +def _read_summary(run_dir: Path) -> str: + try: + return (run_dir / "summary.md").read_text().strip() + except OSError: + return "" + + +def _clean_next_candidate(line: str) -> str: + cleaned = line.strip() + cleaned = re.sub(r"^\s{0,3}#{1,6}\s+", "", cleaned) + cleaned = re.sub(r"^\s{0,3}[-*+]\s+", "", cleaned) + cleaned = cleaned.strip().strip("*").strip() + return cleaned + + +def _collect_next_block(lines: list[str], start: int) -> str | None: + collected: list[str] = [] + for follow in lines[start:]: + follow_stripped = follow.strip() + if not follow_stripped: + if collected: + break + continue + if collected and re.match(r"^\s{0,3}#{1,6}\s+", follow_stripped): + break + if collected and follow_stripped.endswith(":") and not follow_stripped.startswith(("-", "*", "+")): + break + collected.append(follow_stripped) + rendered = "\n".join(item for item in collected if item).strip() + if _clean_next_candidate(rendered).lower() in {"(none extracted)", "none", "n/a"}: + return None + return rendered or None + + def extract_next_step(final_text: str) -> str | None: lines = final_text.splitlines() for index, line in enumerate(lines): - stripped = line.strip().strip("*") + stripped = _clean_next_candidate(line) lowered = stripped.lower() + if lowered == "next": + return _collect_next_block(lines, index + 1) for label in NEXT_LABELS: if not lowered.startswith(label): continue _, _, after = stripped.partition(":") if after.strip(): return after.strip() - collected: list[str] = [] - for follow in lines[index + 1 :]: - follow_stripped = follow.strip() - if not follow_stripped: - if collected: - break - continue - if collected and follow_stripped.endswith(":") and not follow_stripped.startswith(("-", "*")): - break - collected.append(follow_stripped) - return "\n".join(collected).strip() or None + return _collect_next_block(lines, index + 1) return None +def extract_next_step_from_run(run_dir: Path) -> tuple[str | None, str | None]: + final_next = extract_next_step(_read_final(run_dir)) + if final_next: + return final_next, "final" + summary_next = extract_next_step(_read_summary(run_dir)) + if summary_next: + return summary_next, "summary" + return None, None + + def _write_summary(run_dir: Path) -> None: run_meta = _read_run_json(run_dir) if run_meta is None: @@ -290,8 +327,7 @@ def next_step(*, target: Path) -> int: print(f"error: no runs found in {artifacts_dir}", file=sys.stderr) return 1 run_dir, _ = latest_run - final_text = _read_final(run_dir) - extracted = extract_next_step(final_text) + extracted, _ = extract_next_step_from_run(run_dir) if not extracted: print(f"error: no next step found in {run_dir / 'final.txt'}", file=sys.stderr) return 1 @@ -368,7 +404,7 @@ def status(*, target: Path) -> int: ) if task: _setting_line("latest_task", task) - next_step_text = extract_next_step(_read_final(latest_path)) + next_step_text, _ = extract_next_step_from_run(latest_path) if next_step_text: _setting_line("latest_next", " ".join(next_step_text.split())) else: diff --git a/src/brigade/work_cmd.py b/src/brigade/work_cmd.py index a5467fb0..11cbb8b4 100644 --- a/src/brigade/work_cmd.py +++ b/src/brigade/work_cmd.py @@ -102,13 +102,15 @@ def _dogfood_snapshot(target: Path) -> dict[str, Any]: snapshot["next"] = None return snapshot latest_path, latest_meta = latest + next_step, next_source = dogfood_cmd.extract_next_step_from_run(latest_path) snapshot["latest_run"] = { "path": str(latest_path), "started_at": latest_meta.get("started_at"), "status": latest_meta.get("status"), "task": latest_meta.get("task"), } - snapshot["next"] = dogfood_cmd.extract_next_step(dogfood_cmd._read_final(latest_path)) + snapshot["next"] = next_step + snapshot["next_source"] = next_source return snapshot @@ -213,6 +215,30 @@ def _next_step(snapshot: dict[str, Any]) -> str | None: return None +def _session_info(path: Path, payload: dict[str, Any]) -> dict[str, Any]: + snapshot = _snapshot(payload) + notes = payload.get("notes") + latest_note = None + if isinstance(notes, list) and notes: + latest = notes[-1] + if isinstance(latest, dict) and latest.get("text"): + latest_note = latest["text"] + return { + "path": str(path), + "id": payload.get("id", path.name), + "status": payload.get("status", "unknown"), + "title": payload.get("title"), + "started_at": payload.get("started_at"), + "ended_at": payload.get("ended_at"), + "note": payload.get("note"), + "latest_note": latest_note, + "handoff": payload.get("handoff"), + "branch": _branch(snapshot), + "dirty_files": _dirty_count(snapshot), + "next": _next_step(snapshot), + } + + def _resolve_next_task(target: Path) -> dict[str, Any]: dogfood = _dogfood_snapshot(target) next_step = dogfood.get("next") if isinstance(dogfood.get("next"), str) else None @@ -455,6 +481,37 @@ def _next_payload(target: Path) -> dict[str, Any]: } +def _suggested_command(active: dict[str, Any] | None, next_text: object, source: object) -> str: + if active is not None: + return 'brigade work end --note "..." --handoff' + if isinstance(next_text, str) and next_text.strip() and source != "default_review": + return f"brigade work run {shlex.quote(next_text.strip())}" + return "brigade work run" + + +def _brief_payload(target: Path, *, limit: int = 3) -> dict[str, Any]: + target = target.expanduser().resolve() + active = _active_session_info(target) + sessions, skipped = _collect_sessions(_work_root(target)) + latest_session = _session_info(sessions[0][0], sessions[0][1]) if sessions else None + recent_sessions = [_session_info(path, payload) for path, payload in sessions[:limit]] + resolved = _resolve_next_task(target) + git = _git_snapshot(target) + suggested = _suggested_command(active, resolved["task"], resolved["source"]) + return { + "target": str(target), + "git": git, + "active_session": active, + "latest_session": latest_session, + "recent_sessions": recent_sessions, + "skipped_sessions": skipped, + "dogfood": resolved["dogfood"], + "next_source": resolved["source"], + "next": str(resolved["task"]), + "suggested_command": suggested, + } + + def _print_bootstrap_line(level: str, name: str, detail: object) -> None: print(f"[{level}] {name}: {detail}") @@ -807,6 +864,90 @@ def resume(*, target: Path) -> int: return 0 +def brief(*, target: Path, limit: int = 3, json_output: bool = False) -> int: + if limit < 1: + print("error: --limit must be a positive integer", file=sys.stderr) + return 2 + + target = target.expanduser().resolve() + if not target.is_dir(): + print(f"error: --target is not a directory: {target}", file=sys.stderr) + return 2 + + payload = _brief_payload(target, limit=limit) + if json_output: + print(json.dumps(payload, indent=2, sort_keys=True)) + return 0 + + print(f"work brief: {target}") + git = payload["git"] + if isinstance(git, dict) and git.get("available"): + print(f"branch: {git.get('branch')}") + dirty = git.get("dirty_files") if isinstance(git.get("dirty_files"), list) else [] + print(f"dirty_files: {len(dirty)}") + for item in dirty[:8]: + print(f" {item}") + if len(dirty) > 8: + print(f" ... {len(dirty) - 8} more") + else: + print("git: unavailable") + + active = payload["active_session"] + if isinstance(active, dict): + if active.get("valid"): + print(f"active_session: {active.get('path')}") + if active.get("title"): + print(f"active_session_title: {_short(str(active['title']))}") + else: + print(f"active_session: invalid ({active.get('path')})") + else: + print("active_session: none") + + latest_session = payload["latest_session"] + if isinstance(latest_session, dict): + print(f"latest_session: {latest_session.get('path')}") + if latest_session.get("title"): + print(f"latest_session_title: {_short(str(latest_session['title']))}") + if latest_session.get("note"): + print(f"latest_session_note: {_short(str(latest_session['note']))}") + if latest_session.get("handoff"): + print(f"latest_session_handoff: {latest_session['handoff']}") + else: + print(f"latest_session: none ({_work_root(target)})") + + dogfood = payload["dogfood"] + print(f"dogfood_ready: {dogfood.get('ready')}") + if dogfood.get("error"): + print(f"dogfood_error: {dogfood['error']}") + latest_run = dogfood.get("latest_run") + if isinstance(latest_run, dict): + print( + "latest_run: " + f"{latest_run.get('started_at', '')} " + f"[{latest_run.get('status', 'unknown')}] {latest_run.get('path')}" + ) + if latest_run.get("task"): + print(f"latest_task: {_short(str(latest_run['task']))}") + else: + print("latest_run: none") + + print(f"next_source: {payload['next_source']}") + print(f"next: {_short(str(payload['next']))}") + print(f"suggested_command: {payload['suggested_command']}") + + recent = payload["recent_sessions"] + if isinstance(recent, list) and recent: + print("recent_sessions:") + for item in recent: + if not isinstance(item, dict): + continue + title = item.get("title") or item.get("id") + print(f" - {item.get('started_at')} [{item.get('status')}] {_short(str(title))}") + if payload.get("skipped_sessions"): + print(f"skipped_sessions: {payload['skipped_sessions']}", file=sys.stderr) + return 0 + + def next(*, target: Path, json_output: bool = False) -> int: target = target.expanduser().resolve() if not target.is_dir(): diff --git a/tests/test_dogfood_cmd.py b/tests/test_dogfood_cmd.py index bfea6960..373149fa 100644 --- a/tests/test_dogfood_cmd.py +++ b/tests/test_dogfood_cmd.py @@ -291,6 +291,43 @@ def test_dogfood_next_prints_latest_extracted_step(tmp_path, capsys): assert "- Build a thing.\n- Verify it." in capsys.readouterr().out +def test_dogfood_extracts_next_from_markdown_heading(tmp_path): + run_dir = tmp_path / ".brigade" / "runs" / "latest" + run_dir.mkdir(parents=True) + (run_dir / "final.txt").write_text("Result.\n\n## Next\n\n- Build the brief.\n- Run it.\n\n## Notes\n") + + next_step, source = dogfood_cmd.extract_next_step_from_run(run_dir) + + assert source == "final" + assert next_step == "- Build the brief.\n- Run it." + + +def test_dogfood_extracts_next_from_summary_when_final_has_no_label(tmp_path): + run_dir = tmp_path / ".brigade" / "runs" / "latest" + run_dir.mkdir(parents=True) + (run_dir / "final.txt").write_text("No recommendation here.\n") + (run_dir / "summary.md").write_text("# Summary\n\n## Next\n\nBuild the morning brief.\n\n## Final\n") + + next_step, source = dogfood_cmd.extract_next_step_from_run(run_dir) + + assert source == "summary" + assert next_step == "Build the morning brief." + + +def test_dogfood_next_uses_summary_fallback(tmp_path, capsys): + run_dir = tmp_path / ".brigade" / "runs" / "latest" + run_dir.mkdir(parents=True) + _write_json( + run_dir / "run.json", + {"started_at": "2026-05-26T12:00:00Z", "status": "ok", "task": "review"}, + ) + (run_dir / "final.txt").write_text("No recommendation here.\n") + (run_dir / "summary.md").write_text("# Summary\n\n## Next\n\nBuild from summary.\n\n## Final\n") + + assert dogfood_cmd.next_step(target=tmp_path) == 0 + assert "Build from summary." in capsys.readouterr().out + + def test_dogfood_next_reports_missing_step(tmp_path, capsys): run_dir = tmp_path / ".brigade" / "runs" / "latest" run_dir.mkdir(parents=True) diff --git a/tests/test_work_cmd.py b/tests/test_work_cmd.py index 7cae883f..6b40df7d 100644 --- a/tests/test_work_cmd.py +++ b/tests/test_work_cmd.py @@ -418,6 +418,65 @@ def test_work_resume_suggests_work_run_from_latest_next(tmp_path, monkeypatch, c assert "suggested_command: brigade work run 'Build resume.'" in out +def test_work_brief_reports_morning_entrypoint(tmp_path, monkeypatch, capsys): + _init_git_repo(tmp_path) + dogfood_cmd.init(target=tmp_path) + run_dir = tmp_path / ".brigade" / "runs" / "latest" + run_dir.mkdir(parents=True) + _write_json(run_dir / "run.json", {"started_at": "2026-05-26T12:10:00Z", "status": "ok", "task": "review"}) + (run_dir / "final.txt").write_text("Done.\n") + (run_dir / "summary.md").write_text("# Summary\n\n## Next\n\nBuild the morning brief.\n\n## Final\n") + times = iter( + [ + datetime(2026, 5, 26, 12, 0, 0, tzinfo=timezone.utc), + datetime(2026, 5, 26, 13, 0, 0, tzinfo=timezone.utc), + ] + ) + monkeypatch.setattr(work_cmd, "_now", lambda: next(times)) + monkeypatch.setattr(work_cmd.shutil, "which", lambda name: f"/usr/bin/{name}") + assert work_cmd.start(target=tmp_path, title="Ended Work") == 0 + assert work_cmd.end(target=tmp_path, note="done", handoff=True, handoff_inbox=tmp_path / "handoffs") == 0 + + assert work_cmd.brief(target=tmp_path, limit=2) == 0 + out = capsys.readouterr().out + assert "work brief:" in out + assert "active_session: none" in out + assert "latest_session:" in out + assert "latest_session_title: Ended Work" in out + assert "dogfood_ready: True" in out + assert "latest_run: 2026-05-26T12:10:00Z [ok]" in out + assert "next_source: latest_dogfood_run" in out + assert "next: Build the morning brief." in out + assert "suggested_command: brigade work run 'Build the morning brief.'" in out + assert "recent_sessions:" in out + + +def test_work_brief_json_reports_recent_sessions(tmp_path, monkeypatch, capsys): + _init_git_repo(tmp_path) + dogfood_cmd.init(target=tmp_path) + run_dir = tmp_path / ".brigade" / "runs" / "latest" + run_dir.mkdir(parents=True) + _write_json(run_dir / "run.json", {"started_at": "2026-05-26T12:10:00Z", "status": "ok", "task": "review"}) + (run_dir / "final.txt").write_text("Done.\n\n## Next\n\nBuild JSON brief.\n") + monkeypatch.setattr( + work_cmd, + "_now", + lambda: datetime(2026, 5, 26, 12, 0, 0, tzinfo=timezone.utc), + ) + monkeypatch.setattr(work_cmd.shutil, "which", lambda name: f"/usr/bin/{name}") + assert work_cmd.start(target=tmp_path, title="Active Work") == 0 + capsys.readouterr() + + assert work_cmd.brief(target=tmp_path, json_output=True) == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["active_session"]["title"] == "Active Work" + assert payload["latest_session"]["title"] == "Active Work" + assert payload["recent_sessions"][0]["status"] == "active" + assert payload["dogfood"]["next_source"] == "final" + assert payload["next"] == "Build JSON brief." + assert payload["suggested_command"] == 'brigade work end --note "..." --handoff' + + def test_work_next_reports_latest_next_as_default_task(tmp_path, monkeypatch, capsys): _init_git_repo(tmp_path) dogfood_cmd.init(target=tmp_path) @@ -687,6 +746,22 @@ def fake_resume(**kwargs): assert seen == {"target": tmp_path} +def test_work_brief_cli(tmp_path, monkeypatch): + seen = {} + + def fake_brief(**kwargs): + seen.update(kwargs) + return 0 + + monkeypatch.setattr(work_cmd, "brief", fake_brief) + + assert cli.main(["work", "brief", "--target", str(tmp_path), "--limit", "4"]) == 0 + assert seen == {"target": tmp_path, "limit": 4, "json_output": False} + seen.clear() + assert cli.main(["work", "brief", "--target", str(tmp_path), "--json"]) == 0 + assert seen == {"target": tmp_path, "limit": 3, "json_output": True} + + def test_work_next_cli(tmp_path, monkeypatch): seen = {}