Skip to content

Commit bffb7b3

Browse files
authored
Merge pull request #20 from escoffier-labs/feat/brigade-work-run
feat: run brigade work loop
2 parents 346f78a + 7709812 commit bffb7b3

5 files changed

Lines changed: 228 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
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.
3434
- `brigade work recap` to summarize recent or date-filtered work sessions.
35+
- `brigade work run` to start a work session, run dogfood, close the session, write a work handoff, and print a recap in one command.
3536
- Roster-level and per-agent `timeout_seconds` controls for bounded CLI calls.
3637
- `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.
3738

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,8 @@ brigade dogfood
135135
brigade dogfood next
136136
brigade dogfood --target /path/to/repo
137137
brigade work status
138+
brigade work run
139+
brigade work run "review today's changes"
138140
brigade work start "next slice"
139141
brigade work end --note "tests passed" --handoff
140142
brigade work list
@@ -161,7 +163,7 @@ CLI runs write artifacts by default under `.brigade/runs/<id>` below `--cwd`; do
161163

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

164-
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`.
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`.
165167

166168
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.
167169

src/brigade/cli.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,26 @@ def _build_parser() -> argparse.ArgumentParser:
126126
p_work_recap.add_argument("--target", "-t", type=Path, default=Path("."), help="Repo or workspace to inspect.")
127127
p_work_recap.add_argument("--limit", type=int, default=5, help="Maximum sessions to include.")
128128
p_work_recap.add_argument("--since", default=None, help="Only include sessions since YYYY-MM-DD.")
129+
p_work_run = work_sub.add_parser("run", help="Start a work session, run dogfood, end it, and recap.")
130+
p_work_run.add_argument("task", nargs="*", help="Dogfood task. Defaults to the standard next-slice review.")
131+
p_work_run.add_argument("--target", "-t", type=Path, default=Path("."), help="Repo or workspace for the session.")
132+
p_work_run.add_argument("--title", default=None, help="Work session title. Defaults to the task text.")
133+
p_work_run.add_argument("--output-dir", type=Path, default=None, help="Directory for dogfood run artifacts.")
134+
p_work_run.add_argument("--handoff-inbox", type=Path, default=None, help="Memory Handoff inbox.")
135+
p_work_run.add_argument("--no-handoff", action="store_true", help="Do not write a work-session Memory Handoff.")
136+
p_work_run.add_argument(
137+
"--dogfood-handoff",
138+
action="store_true",
139+
help="Also let the underlying dogfood run write its own Memory Handoff.",
140+
)
141+
p_work_run.add_argument("--no-inspect", action="store_true", help="Do not print the dogfood artifact summary.")
142+
p_work_run.add_argument(
143+
"--native-read-only-sandbox",
144+
action="store_true",
145+
help="Use Codex's native read-only sandbox for the underlying dogfood run.",
146+
)
147+
p_work_run.add_argument("--timeout-seconds", type=float, default=DEFAULT_TIMEOUT_SECONDS, help="Per-agent timeout.")
148+
p_work_run.add_argument("--recap-limit", type=int, default=1, help="Maximum sessions to include in the final recap.")
129149
p_work_start = work_sub.add_parser("start", help="Start a local Brigade work session.")
130150
p_work_start.add_argument("title", nargs="*", help="Optional session title.")
131151
p_work_start.add_argument("--target", "-t", type=Path, default=Path("."), help="Repo or workspace for the session.")
@@ -420,6 +440,21 @@ def main(argv=None) -> int:
420440
return work_cmd.show(target=args.target, session=args.session)
421441
if args.work_command == "recap":
422442
return work_cmd.recap(target=args.target, limit=args.limit, since=args.since)
443+
if args.work_command == "run":
444+
task = " ".join(args.task) if args.task else None
445+
return work_cmd.run(
446+
task,
447+
target=args.target,
448+
title=args.title,
449+
output_dir=args.output_dir,
450+
handoff=not args.no_handoff,
451+
handoff_inbox=args.handoff_inbox,
452+
dogfood_handoff=args.dogfood_handoff,
453+
inspect=not args.no_inspect,
454+
native_read_only_sandbox=args.native_read_only_sandbox,
455+
timeout_seconds=args.timeout_seconds,
456+
recap_limit=args.recap_limit,
457+
)
423458
if args.work_command == "start":
424459
title = " ".join(args.title) if args.title else None
425460
return work_cmd.start(target=args.target, title=title, force=args.force)

src/brigade/work_cmd.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -576,6 +576,57 @@ def recap(*, target: Path, limit: int = 5, since: str | None = None) -> int:
576576
return 0
577577

578578

579+
def run(
580+
task: str | None,
581+
*,
582+
target: Path,
583+
title: str | None = None,
584+
output_dir: Path | None = None,
585+
handoff: bool = True,
586+
handoff_inbox: Path | None = None,
587+
dogfood_handoff: bool = False,
588+
inspect: bool = True,
589+
native_read_only_sandbox: bool = False,
590+
timeout_seconds: float = dogfood_cmd.DEFAULT_TIMEOUT_SECONDS,
591+
recap_limit: int = 1,
592+
) -> int:
593+
if recap_limit < 1:
594+
print("error: --recap-limit must be a positive integer", file=sys.stderr)
595+
return 2
596+
597+
target = target.expanduser().resolve()
598+
if not target.is_dir():
599+
print(f"error: --target is not a directory: {target}", file=sys.stderr)
600+
return 2
601+
602+
task_text = task or dogfood_cmd.DEFAULT_TASK
603+
session_title = title or task_text
604+
start_rc = start(target=target, title=session_title)
605+
if start_rc != 0:
606+
return start_rc
607+
608+
dogfood_rc = 1
609+
try:
610+
dogfood_rc = dogfood_cmd.run(
611+
task_text,
612+
target=target,
613+
output_dir=output_dir,
614+
handoff=dogfood_handoff,
615+
handoff_inbox=handoff_inbox if dogfood_handoff else None,
616+
inspect=inspect,
617+
native_read_only_sandbox=native_read_only_sandbox,
618+
timeout_seconds=timeout_seconds,
619+
)
620+
finally:
621+
note = f"brigade work run completed with dogfood exit code {dogfood_rc}"
622+
end_rc = end(target=target, note=note, handoff=handoff, handoff_inbox=handoff_inbox)
623+
624+
if end_rc != 0:
625+
return end_rc if dogfood_rc == 0 else dogfood_rc
626+
recap(target=target, limit=recap_limit)
627+
return dogfood_rc
628+
629+
579630
def status(*, target: Path, limit: int = 12) -> int:
580631
if limit < 1:
581632
print("error: --limit must be a positive integer", file=sys.stderr)

tests/test_work_cmd.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,92 @@ def test_work_recap_rejects_bad_since(tmp_path, capsys):
268268
assert "--since must use YYYY-MM-DD" in capsys.readouterr().err
269269

270270

271+
def test_work_run_wraps_dogfood_session(tmp_path, monkeypatch, capsys):
272+
_init_git_repo(tmp_path)
273+
artifacts_dir = tmp_path / ".brigade" / "runs"
274+
dogfood_cmd.init(target=tmp_path, artifacts_dir=artifacts_dir)
275+
times = iter(
276+
[
277+
datetime(2026, 5, 26, 12, 0, 0, tzinfo=timezone.utc),
278+
datetime(2026, 5, 26, 13, 0, 0, tzinfo=timezone.utc),
279+
]
280+
)
281+
monkeypatch.setattr(work_cmd, "_now", lambda: next(times))
282+
seen = {}
283+
284+
def fake_dogfood_run(task, **kwargs):
285+
seen["task"] = task
286+
seen.update(kwargs)
287+
run_dir = kwargs["output_dir"]
288+
run_dir.mkdir(parents=True)
289+
_write_json(
290+
run_dir / "run.json",
291+
{"started_at": "2026-05-26T12:10:00Z", "status": "ok", "task": task},
292+
)
293+
(run_dir / "final.txt").write_text("Done.\n\nNext step: Build work run.\n")
294+
return 0
295+
296+
monkeypatch.setattr(dogfood_cmd, "run", fake_dogfood_run)
297+
run_dir = artifacts_dir / "work-run"
298+
299+
assert (
300+
work_cmd.run(
301+
"review the repo",
302+
target=tmp_path,
303+
title="Daily Review",
304+
output_dir=run_dir,
305+
handoff_inbox=tmp_path / "handoffs",
306+
)
307+
== 0
308+
)
309+
assert seen["task"] == "review the repo"
310+
assert seen["target"] == tmp_path.resolve()
311+
assert seen["output_dir"] == run_dir
312+
assert seen["handoff"] is False
313+
assert seen["handoff_inbox"] is None
314+
assert seen["inspect"] is True
315+
assert not (tmp_path / ".brigade" / "work" / "current").exists()
316+
session_dir = tmp_path / ".brigade" / "work" / "20260526-120000-daily-review"
317+
payload = json.loads((session_dir / "session.json").read_text())
318+
assert payload["status"] == "ended"
319+
assert payload["note"] == "brigade work run completed with dogfood exit code 0"
320+
assert payload["end"]["dogfood"]["latest_run"]["path"] == str(run_dir)
321+
assert payload["end"]["dogfood"]["next"] == "Build work run."
322+
assert "handoff" in payload
323+
out = capsys.readouterr().out
324+
assert "work recap:" in out
325+
assert "Daily Review" in out
326+
assert "next: Build work run." in out
327+
328+
329+
def test_work_run_closes_session_when_dogfood_fails(tmp_path, monkeypatch):
330+
_init_git_repo(tmp_path)
331+
dogfood_cmd.init(target=tmp_path)
332+
times = iter(
333+
[
334+
datetime(2026, 5, 26, 12, 0, 0, tzinfo=timezone.utc),
335+
datetime(2026, 5, 26, 13, 0, 0, tzinfo=timezone.utc),
336+
]
337+
)
338+
monkeypatch.setattr(work_cmd, "_now", lambda: next(times))
339+
monkeypatch.setattr(dogfood_cmd, "run", lambda task, **kwargs: 7)
340+
341+
assert work_cmd.run("review the repo", target=tmp_path, handoff=False) == 7
342+
assert not (tmp_path / ".brigade" / "work" / "current").exists()
343+
session_dir = tmp_path / ".brigade" / "work" / "20260526-120000-review-the-repo"
344+
payload = json.loads((session_dir / "session.json").read_text())
345+
assert payload["status"] == "ended"
346+
assert payload["note"] == "brigade work run completed with dogfood exit code 7"
347+
assert "handoff" not in payload
348+
349+
350+
def test_work_run_rejects_bad_recap_limit(tmp_path, capsys):
351+
_init_git_repo(tmp_path)
352+
353+
assert work_cmd.run(None, target=tmp_path, recap_limit=0) == 2
354+
assert "--recap-limit must be a positive integer" in capsys.readouterr().err
355+
356+
271357
def test_work_status_cli(tmp_path, monkeypatch):
272358
seen = {}
273359

@@ -326,6 +412,58 @@ def fake_end(**kwargs):
326412
]
327413

328414

415+
def test_work_run_cli(tmp_path, monkeypatch):
416+
seen = {}
417+
418+
def fake_run(task, **kwargs):
419+
seen["task"] = task
420+
seen.update(kwargs)
421+
return 0
422+
423+
monkeypatch.setattr(work_cmd, "run", fake_run)
424+
425+
assert (
426+
cli.main(
427+
[
428+
"work",
429+
"run",
430+
"review",
431+
"repo",
432+
"--target",
433+
str(tmp_path),
434+
"--title",
435+
"Daily",
436+
"--output-dir",
437+
str(tmp_path / "run"),
438+
"--handoff-inbox",
439+
str(tmp_path / "handoffs"),
440+
"--no-handoff",
441+
"--dogfood-handoff",
442+
"--no-inspect",
443+
"--native-read-only-sandbox",
444+
"--timeout-seconds",
445+
"12",
446+
"--recap-limit",
447+
"2",
448+
]
449+
)
450+
== 0
451+
)
452+
assert seen == {
453+
"task": "review repo",
454+
"target": tmp_path,
455+
"title": "Daily",
456+
"output_dir": tmp_path / "run",
457+
"handoff": False,
458+
"handoff_inbox": tmp_path / "handoffs",
459+
"dogfood_handoff": True,
460+
"inspect": False,
461+
"native_read_only_sandbox": True,
462+
"timeout_seconds": 12.0,
463+
"recap_limit": 2,
464+
}
465+
466+
329467
def test_work_inspection_cli(tmp_path, monkeypatch):
330468
seen = []
331469

0 commit comments

Comments
 (0)