Skip to content

Commit 16fa748

Browse files
authored
Merge pull request #22 from escoffier-labs/feat/brigade-work-resume
feat: resume brigade work sessions
2 parents 913e12c + 7af66a8 commit 16fa748

5 files changed

Lines changed: 159 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3333
- `brigade work list`, `brigade work latest`, and `brigade work show` to inspect local work session artifacts.
3434
- `brigade work recap` to summarize recent or date-filtered work sessions.
3535
- `brigade work run` to start a work session, run dogfood, close the session, write a work handoff, and print a recap in one command.
36+
- `brigade work resume` to show the active or latest work session, latest dogfood run, extracted next step, and suggested command.
3637
- Roster-level and per-agent `timeout_seconds` controls for bounded CLI calls.
3738
- `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.
3839

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ brigade dogfood
135135
brigade dogfood next
136136
brigade dogfood --target /path/to/repo
137137
brigade work status
138+
brigade work resume
138139
brigade work run
139140
brigade work run "review today's changes"
140141
brigade work start "next slice"
@@ -163,7 +164,7 @@ CLI runs write artifacts by default under `.brigade/runs/<id>` below `--cwd`; do
163164

164165
Use `--output-dir <path>` to pick the artifact directory, or `--no-artifacts` for a throwaway run.
165166

166-
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 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/<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`.
167+
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 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/<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 `.codex/memory-handoffs`.
167168

168169
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.
169170

src/brigade/cli.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,8 @@ def _build_parser() -> argparse.ArgumentParser:
114114
p_work_status = work_sub.add_parser("status", help="Show current repo and dogfood work state.")
115115
p_work_status.add_argument("--target", "-t", type=Path, default=Path("."), help="Repo or workspace to inspect.")
116116
p_work_status.add_argument("--limit", type=int, default=12, help="Maximum dirty file entries to show.")
117+
p_work_resume = work_sub.add_parser("resume", help="Show the current work handoff point and next command.")
118+
p_work_resume.add_argument("--target", "-t", type=Path, default=Path("."), help="Repo or workspace to inspect.")
117119
p_work_list = work_sub.add_parser("list", help="List recent Brigade work sessions.")
118120
p_work_list.add_argument("--target", "-t", type=Path, default=Path("."), help="Repo or workspace to inspect.")
119121
p_work_list.add_argument("--limit", type=int, default=10, help="Maximum sessions to show.")
@@ -432,6 +434,8 @@ def main(argv=None) -> int:
432434

433435
if args.work_command == "status":
434436
return work_cmd.status(target=args.target, limit=args.limit)
437+
if args.work_command == "resume":
438+
return work_cmd.resume(target=args.target)
435439
if args.work_command == "list":
436440
return work_cmd.list_sessions(target=args.target, limit=args.limit)
437441
if args.work_command == "latest":

src/brigade/work_cmd.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import json
55
import re
6+
import shlex
67
import shutil
78
import subprocess
89
import sys
@@ -580,6 +581,81 @@ def recap(*, target: Path, limit: int = 5, since: str | None = None) -> int:
580581
return 0
581582

582583

584+
def _print_resume_session(label: str, path: Path, payload: dict[str, Any]) -> None:
585+
print(f"{label}: {path}")
586+
print(f"{label}_status: {payload.get('status', 'unknown')}")
587+
if payload.get("title"):
588+
print(f"{label}_title: {_short(str(payload['title']))}")
589+
print(f"{label}_started: {payload.get('started_at', '')}")
590+
if payload.get("ended_at"):
591+
print(f"{label}_ended: {payload['ended_at']}")
592+
if payload.get("note"):
593+
print(f"{label}_note: {_short(str(payload['note']))}")
594+
if payload.get("handoff"):
595+
print(f"{label}_handoff: {payload['handoff']}")
596+
597+
598+
def resume(*, target: Path) -> int:
599+
target = target.expanduser().resolve()
600+
if not target.is_dir():
601+
print(f"error: --target is not a directory: {target}", file=sys.stderr)
602+
return 2
603+
604+
print(f"work resume: {target}")
605+
root = _work_root(target)
606+
current = _current_path(target)
607+
active_payload: dict[str, Any] | None = None
608+
if current.exists():
609+
active_dir = root / current.read_text().strip()
610+
active_payload = _read_session(active_dir)
611+
if active_payload is None:
612+
print(f"active_session: invalid ({active_dir})")
613+
else:
614+
_print_resume_session("active_session", active_dir, active_payload)
615+
else:
616+
print("active_session: none")
617+
618+
sessions, skipped = _collect_sessions(root)
619+
if skipped:
620+
print(f"skipped: {skipped}", file=sys.stderr)
621+
if sessions:
622+
latest_path, latest_payload = sessions[0]
623+
if active_payload is None or latest_payload.get("id") != active_payload.get("id"):
624+
_print_resume_session("latest_session", latest_path, latest_payload)
625+
else:
626+
print(f"latest_session: none ({root})")
627+
628+
dogfood = _dogfood_snapshot(target)
629+
print(f"dogfood_ready: {dogfood.get('ready')}")
630+
if dogfood.get("error"):
631+
print(f"dogfood_error: {dogfood['error']}")
632+
if dogfood.get("target"):
633+
print(f"dogfood_target: {dogfood['target']}")
634+
if dogfood.get("artifacts_dir"):
635+
print(f"dogfood_artifacts: {dogfood['artifacts_dir']}")
636+
latest_run = dogfood.get("latest_run")
637+
if isinstance(latest_run, dict):
638+
print(
639+
"latest_run: "
640+
f"{latest_run.get('started_at', '')} "
641+
f"[{latest_run.get('status', 'unknown')}] {latest_run.get('path')}"
642+
)
643+
if latest_run.get("task"):
644+
print(f"latest_task: {_short(str(latest_run['task']))}")
645+
else:
646+
print("latest_run: none")
647+
648+
next_step = dogfood.get("next") if isinstance(dogfood.get("next"), str) else None
649+
print(f"next: {_short(next_step) if next_step else 'none'}")
650+
if active_payload is not None:
651+
print('suggested_command: brigade work end --note "..." --handoff')
652+
elif next_step:
653+
print(f"suggested_command: brigade work run {shlex.quote(next_step)}")
654+
else:
655+
print("suggested_command: brigade work run")
656+
return 0
657+
658+
583659
def run(
584660
task: str | None,
585661
*,

tests/test_work_cmd.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,69 @@ def test_work_recap_rejects_bad_since(tmp_path, capsys):
285285
assert "--since must use YYYY-MM-DD" in capsys.readouterr().err
286286

287287

288+
def test_work_resume_reports_active_session(tmp_path, monkeypatch, capsys):
289+
_init_git_repo(tmp_path)
290+
dogfood_cmd.init(target=tmp_path)
291+
run_dir = tmp_path / ".brigade" / "runs" / "latest"
292+
run_dir.mkdir(parents=True)
293+
_write_json(run_dir / "run.json", {"started_at": "2026-05-26T12:10:00Z", "status": "ok", "task": "review"})
294+
(run_dir / "final.txt").write_text("Done.\n\nNext step: Build resume.\n")
295+
monkeypatch.setattr(
296+
work_cmd,
297+
"_now",
298+
lambda: datetime(2026, 5, 26, 12, 0, 0, tzinfo=timezone.utc),
299+
)
300+
assert work_cmd.start(target=tmp_path, title="Active Work") == 0
301+
302+
assert work_cmd.resume(target=tmp_path) == 0
303+
out = capsys.readouterr().out
304+
assert "work resume:" in out
305+
assert "active_session:" in out
306+
assert "active_session_title: Active Work" in out
307+
assert "latest_run: 2026-05-26T12:10:00Z [ok]" in out
308+
assert "next: Build resume." in out
309+
assert 'suggested_command: brigade work end --note "..." --handoff' in out
310+
311+
312+
def test_work_resume_suggests_work_run_from_latest_next(tmp_path, monkeypatch, capsys):
313+
_init_git_repo(tmp_path)
314+
dogfood_cmd.init(target=tmp_path)
315+
run_dir = tmp_path / ".brigade" / "runs" / "latest"
316+
run_dir.mkdir(parents=True)
317+
_write_json(run_dir / "run.json", {"started_at": "2026-05-26T12:10:00Z", "status": "ok", "task": "review"})
318+
(run_dir / "final.txt").write_text("Done.\n\nNext step: Build resume.\n")
319+
times = iter(
320+
[
321+
datetime(2026, 5, 26, 12, 0, 0, tzinfo=timezone.utc),
322+
datetime(2026, 5, 26, 13, 0, 0, tzinfo=timezone.utc),
323+
]
324+
)
325+
monkeypatch.setattr(work_cmd, "_now", lambda: next(times))
326+
assert work_cmd.start(target=tmp_path, title="Ended Work") == 0
327+
assert work_cmd.end(target=tmp_path, note="done", handoff=True, handoff_inbox=tmp_path / "handoffs") == 0
328+
329+
assert work_cmd.resume(target=tmp_path) == 0
330+
out = capsys.readouterr().out
331+
assert "active_session: none" in out
332+
assert "latest_session:" in out
333+
assert "latest_session_title: Ended Work" in out
334+
assert "latest_session_handoff:" in out
335+
assert "next: Build resume." in out
336+
assert "suggested_command: brigade work run 'Build resume.'" in out
337+
338+
339+
def test_work_resume_empty_state(tmp_path, capsys):
340+
_init_git_repo(tmp_path)
341+
342+
assert work_cmd.resume(target=tmp_path) == 0
343+
out = capsys.readouterr().out
344+
assert "active_session: none" in out
345+
assert "latest_session: none" in out
346+
assert "latest_run: none" in out
347+
assert "next: none" in out
348+
assert "suggested_command: brigade work run" in out
349+
350+
288351
def test_work_run_wraps_dogfood_session(tmp_path, monkeypatch, capsys):
289352
_init_git_repo(tmp_path)
290353
artifacts_dir = tmp_path / ".brigade" / "runs"
@@ -384,6 +447,19 @@ def fake_status(**kwargs):
384447
assert seen == {"target": tmp_path, "limit": 3}
385448

386449

450+
def test_work_resume_cli(tmp_path, monkeypatch):
451+
seen = {}
452+
453+
def fake_resume(**kwargs):
454+
seen.update(kwargs)
455+
return 0
456+
457+
monkeypatch.setattr(work_cmd, "resume", fake_resume)
458+
459+
assert cli.main(["work", "resume", "--target", str(tmp_path)]) == 0
460+
assert seen == {"target": tmp_path}
461+
462+
387463
def test_work_start_and_end_cli(tmp_path, monkeypatch):
388464
seen = []
389465

0 commit comments

Comments
 (0)