Skip to content

Commit 346f78a

Browse files
authored
Merge pull request #19 from escoffier-labs/feat/brigade-work-recap
feat: recap brigade work sessions
2 parents a51e5d5 + 900c508 commit 346f78a

5 files changed

Lines changed: 170 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3131
- `brigade work start` and `brigade work end` to create local `.brigade/work/` session artifacts for normal daily work loops.
3232
- `brigade work end --handoff` to write a Memory Handoff from closed work session artifacts.
3333
- `brigade work list`, `brigade work latest`, and `brigade work show` to inspect local work session artifacts.
34+
- `brigade work recap` to summarize recent or date-filtered work sessions.
3435
- Roster-level and per-agent `timeout_seconds` controls for bounded CLI calls.
3536
- `brigade run --read-only` prompt policy for planning and review runs that should inspect and recommend only, with native `codex exec --sandbox read-only` enforcement for Codex agents.
3637

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ brigade work start "next slice"
139139
brigade work end --note "tests passed" --handoff
140140
brigade work list
141141
brigade work latest
142+
brigade work recap --since 2026-05-26
142143
```
143144

144145
`--dry-run` prints the planned assignments as JSON and stops before worker dispatch. `--show-plan` prints assignments before a normal run. `--verbose` prints the plan, worker statuses, and synthesis status. `--cwd` sets the working directory for the agent CLI calls and defaults to the current directory. `--handoff` writes a Memory Handoff for a successful non-dry run. `--inspect` prints the same readable artifact summary as `brigade runs show` after the run completes. `--read-only` tells the orchestrator and workers to inspect and recommend only, without modifying files or external state. For `codex` agents, Brigade also passes `codex exec --sandbox read-only`; other adapters receive the prompt policy only. The `cli` values are adapters for installed command-line tools: `codex`, `claude`, and `ollama:<model>`. Pick the ones you already use. Brigade shells out to those tools and keeps no provider keys. `brigade roster doctor` validates the roster syntax and reports which CLIs are present on `PATH`.
@@ -162,7 +163,7 @@ Use `--output-dir <path>` to pick the artifact directory, or `--no-artifacts` fo
162163

163164
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. `brigade work start "title"` opens a local work session under `.brigade/work/<id>/`, records the starting git and dogfood context, and writes `start.md`. `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 `.claude/memory-handoffs`.
164165

165-
Inspect local work sessions with `brigade work list`, `brigade work latest`, or `brigade work show <session-id-or-path>`.
166+
Inspect local work sessions with `brigade work list`, `brigade work latest`, or `brigade work show <session-id-or-path>`. Use `brigade work recap` for a compact summary of recent sessions, or add `--since YYYY-MM-DD` for a day-range recap.
166167

167168
Inspect a completed run without opening each JSON file:
168169

src/brigade/cli.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,10 @@ def _build_parser() -> argparse.ArgumentParser:
122122
p_work_show = work_sub.add_parser("show", help="Show one Brigade work session.")
123123
p_work_show.add_argument("session", help="Session id or path.")
124124
p_work_show.add_argument("--target", "-t", type=Path, default=Path("."), help="Repo or workspace to inspect.")
125+
p_work_recap = work_sub.add_parser("recap", help="Summarize recent Brigade work sessions.")
126+
p_work_recap.add_argument("--target", "-t", type=Path, default=Path("."), help="Repo or workspace to inspect.")
127+
p_work_recap.add_argument("--limit", type=int, default=5, help="Maximum sessions to include.")
128+
p_work_recap.add_argument("--since", default=None, help="Only include sessions since YYYY-MM-DD.")
125129
p_work_start = work_sub.add_parser("start", help="Start a local Brigade work session.")
126130
p_work_start.add_argument("title", nargs="*", help="Optional session title.")
127131
p_work_start.add_argument("--target", "-t", type=Path, default=Path("."), help="Repo or workspace for the session.")
@@ -414,6 +418,8 @@ def main(argv=None) -> int:
414418
return work_cmd.latest(target=args.target)
415419
if args.work_command == "show":
416420
return work_cmd.show(target=args.target, session=args.session)
421+
if args.work_command == "recap":
422+
return work_cmd.recap(target=args.target, limit=args.limit, since=args.since)
417423
if args.work_command == "start":
418424
title = " ".join(args.title) if args.title else None
419425
return work_cmd.start(target=args.target, title=title, force=args.force)

src/brigade/work_cmd.py

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import shutil
77
import subprocess
88
import sys
9-
from datetime import datetime, timezone
9+
from datetime import datetime, time, timezone
1010
from pathlib import Path
1111
from typing import Any
1212
from uuid import uuid4
@@ -125,6 +125,29 @@ def _session_sort_key(item: tuple[Path, dict[str, Any]]) -> str:
125125
return str(payload.get("ended_at") or payload.get("started_at") or path.name)
126126

127127

128+
def _parse_iso_datetime(value: object) -> datetime | None:
129+
if not isinstance(value, str) or not value:
130+
return None
131+
normalized = value.replace("Z", "+00:00")
132+
try:
133+
parsed = datetime.fromisoformat(normalized)
134+
except ValueError:
135+
return None
136+
if parsed.tzinfo is None:
137+
return parsed.replace(tzinfo=timezone.utc)
138+
return parsed.astimezone(timezone.utc)
139+
140+
141+
def _parse_since(value: str | None) -> datetime | None:
142+
if value is None:
143+
return None
144+
try:
145+
parsed_date = datetime.strptime(value, "%Y-%m-%d").date()
146+
except ValueError as exc:
147+
raise ValueError("--since must use YYYY-MM-DD") from exc
148+
return datetime.combine(parsed_date, time.min, tzinfo=timezone.utc)
149+
150+
128151
def _collect_sessions(root: Path) -> tuple[list[tuple[Path, dict[str, Any]]], int]:
129152
sessions: list[tuple[Path, dict[str, Any]]] = []
130153
skipped = 0
@@ -157,6 +180,28 @@ def _dirty_count(snapshot: dict[str, Any]) -> int:
157180
return len(dirty) if isinstance(dirty, list) else 0
158181

159182

183+
def _snapshot(payload: dict[str, Any]) -> dict[str, Any]:
184+
if isinstance(payload.get("end"), dict):
185+
return payload["end"]
186+
if isinstance(payload.get("start"), dict):
187+
return payload["start"]
188+
return {}
189+
190+
191+
def _branch(snapshot: dict[str, Any]) -> str | None:
192+
git = snapshot.get("git")
193+
if isinstance(git, dict) and isinstance(git.get("branch"), str):
194+
return git["branch"]
195+
return None
196+
197+
198+
def _next_step(snapshot: dict[str, Any]) -> str | None:
199+
dogfood = snapshot.get("dogfood")
200+
if isinstance(dogfood, dict) and isinstance(dogfood.get("next"), str):
201+
return dogfood["next"]
202+
return None
203+
204+
160205
def _display_session(path: Path, payload: dict[str, Any]) -> None:
161206
print(f"session: {path}")
162207
print(f"id: {payload.get('id', path.name)}")
@@ -465,6 +510,72 @@ def show(*, target: Path, session: str | Path) -> int:
465510
return 0
466511

467512

513+
def recap(*, target: Path, limit: int = 5, since: str | None = None) -> int:
514+
if limit < 1:
515+
print("error: --limit must be a positive integer", file=sys.stderr)
516+
return 2
517+
try:
518+
since_dt = _parse_since(since)
519+
except ValueError as exc:
520+
print(f"error: {exc}", file=sys.stderr)
521+
return 2
522+
523+
target = target.expanduser().resolve()
524+
if not target.is_dir():
525+
print(f"error: --target is not a directory: {target}", file=sys.stderr)
526+
return 2
527+
root = _work_root(target)
528+
sessions, skipped = _collect_sessions(root)
529+
if since_dt is not None:
530+
sessions = [
531+
(path, payload)
532+
for path, payload in sessions
533+
if (_parse_iso_datetime(payload.get("ended_at") or payload.get("started_at")) or datetime.min.replace(tzinfo=timezone.utc))
534+
>= since_dt
535+
]
536+
sessions = sessions[:limit]
537+
538+
print(f"work recap: {target}")
539+
if since:
540+
print(f"since: {since}")
541+
print(f"sessions: {len(sessions)}")
542+
if skipped:
543+
print(f"skipped: {skipped}", file=sys.stderr)
544+
if not sessions:
545+
print(f"no work sessions found in {root}")
546+
return 0
547+
548+
branches = sorted({branch for _, payload in sessions if (branch := _branch(_snapshot(payload)))})
549+
if branches:
550+
print(f"branches: {', '.join(branches)}")
551+
handoffs = [str(payload.get("handoff")) for _, payload in sessions if payload.get("handoff")]
552+
if handoffs:
553+
print(f"handoffs: {len(handoffs)}")
554+
555+
print("items:")
556+
for path, payload in sessions:
557+
snapshot = _snapshot(payload)
558+
title = str(payload.get("title") or payload.get("id") or path.name)
559+
print(f"- {title}")
560+
print(f" id: {payload.get('id', path.name)}")
561+
print(f" status: {payload.get('status', 'unknown')}")
562+
print(f" started: {payload.get('started_at', '')}")
563+
if payload.get("ended_at"):
564+
print(f" ended: {payload['ended_at']}")
565+
branch = _branch(snapshot)
566+
if branch:
567+
print(f" branch: {branch}")
568+
print(f" dirty_files: {_dirty_count(snapshot)}")
569+
if payload.get("note"):
570+
print(f" note: {_short(str(payload['note']))}")
571+
if payload.get("handoff"):
572+
print(f" handoff: {payload['handoff']}")
573+
next_text = _next_step(snapshot)
574+
if next_text:
575+
print(f" next: {_short(next_text)}")
576+
return 0
577+
578+
468579
def status(*, target: Path, limit: int = 12) -> int:
469580
if limit < 1:
470581
print("error: --limit must be a positive integer", file=sys.stderr)

tests/test_work_cmd.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,48 @@ def test_work_latest_reports_no_sessions(tmp_path, capsys):
226226
assert "no work sessions found" in capsys.readouterr().err
227227

228228

229+
def test_work_recap_summarizes_recent_sessions(tmp_path, monkeypatch, capsys):
230+
_init_git_repo(tmp_path)
231+
times = iter(
232+
[
233+
datetime(2026, 5, 25, 12, 0, 0, tzinfo=timezone.utc),
234+
datetime(2026, 5, 25, 13, 0, 0, tzinfo=timezone.utc),
235+
datetime(2026, 5, 26, 12, 0, 0, tzinfo=timezone.utc),
236+
datetime(2026, 5, 26, 13, 0, 0, tzinfo=timezone.utc),
237+
]
238+
)
239+
monkeypatch.setattr(work_cmd, "_now", lambda: next(times))
240+
dogfood_cmd.init(target=tmp_path)
241+
run_dir = tmp_path / ".brigade" / "runs" / "latest"
242+
run_dir.mkdir(parents=True)
243+
_write_json(run_dir / "run.json", {"started_at": "2026-05-26T11:00:00Z", "status": "ok", "task": "review"})
244+
(run_dir / "final.txt").write_text("Done.\n\nNext step: Build recap.\n")
245+
246+
assert work_cmd.start(target=tmp_path, title="Older Session") == 0
247+
assert work_cmd.end(target=tmp_path, note="old note") == 0
248+
assert work_cmd.start(target=tmp_path, title="Newer Session") == 0
249+
assert work_cmd.end(target=tmp_path, note="new note", handoff=True, handoff_inbox=tmp_path / "handoffs") == 0
250+
251+
assert work_cmd.recap(target=tmp_path, since="2026-05-26", limit=5) == 0
252+
out = capsys.readouterr().out
253+
assert "work recap:" in out
254+
assert "since: 2026-05-26" in out
255+
assert "sessions: 1" in out
256+
assert "branches:" in out
257+
assert "handoffs: 1" in out
258+
assert "Newer Session" in out
259+
assert "Older Session" not in out
260+
assert "note: new note" in out
261+
assert "next: Build recap." in out
262+
263+
264+
def test_work_recap_rejects_bad_since(tmp_path, capsys):
265+
_init_git_repo(tmp_path)
266+
267+
assert work_cmd.recap(target=tmp_path, since="05-26-2026") == 2
268+
assert "--since must use YYYY-MM-DD" in capsys.readouterr().err
269+
270+
229271
def test_work_status_cli(tmp_path, monkeypatch):
230272
seen = {}
231273

@@ -299,15 +341,22 @@ def fake_show(**kwargs):
299341
seen.append(("show", kwargs))
300342
return 0
301343

344+
def fake_recap(**kwargs):
345+
seen.append(("recap", kwargs))
346+
return 0
347+
302348
monkeypatch.setattr(work_cmd, "list_sessions", fake_list)
303349
monkeypatch.setattr(work_cmd, "latest", fake_latest)
304350
monkeypatch.setattr(work_cmd, "show", fake_show)
351+
monkeypatch.setattr(work_cmd, "recap", fake_recap)
305352

306353
assert cli.main(["work", "list", "--target", str(tmp_path), "--limit", "2"]) == 0
307354
assert cli.main(["work", "latest", "--target", str(tmp_path)]) == 0
308355
assert cli.main(["work", "show", "abc123", "--target", str(tmp_path)]) == 0
356+
assert cli.main(["work", "recap", "--target", str(tmp_path), "--since", "2026-05-26", "--limit", "3"]) == 0
309357
assert seen == [
310358
("list", {"target": tmp_path, "limit": 2}),
311359
("latest", {"target": tmp_path}),
312360
("show", {"target": tmp_path, "session": "abc123"}),
361+
("recap", {"target": tmp_path, "limit": 3, "since": "2026-05-26"}),
313362
]

0 commit comments

Comments
 (0)