Skip to content

Commit a930869

Browse files
committed
feat: write brigade work handoffs
1 parent 1046482 commit a930869

5 files changed

Lines changed: 165 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2929
- `brigade runs show <run-dir>` to print a readable summary of one run artifact directory.
3030
- `brigade work status` to report the current repo branch, dirty files, dogfood readiness, latest run, and extracted next step for daily work sessions.
3131
- `brigade work start` and `brigade work end` to create local `.brigade/work/` session artifacts for normal daily work loops.
32+
- `brigade work end --handoff` to write a Memory Handoff from closed work session artifacts.
3233
- Roster-level and per-agent `timeout_seconds` controls for bounded CLI calls.
3334
- `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.
3435

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ brigade dogfood next
136136
brigade dogfood --target /path/to/repo
137137
brigade work status
138138
brigade work start "next slice"
139-
brigade work end --note "tests passed"
139+
brigade work end --note "tests passed" --handoff
140140
```
141141

142142
`--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`.
@@ -158,7 +158,7 @@ CLI runs write artifacts by default under `.brigade/runs/<id>` below `--cwd`; do
158158

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

161-
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`.
161+
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`.
162162

163163
Inspect a completed run without opening each JSON file:
164164

src/brigade/cli.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,13 @@ def _build_parser() -> argparse.ArgumentParser:
121121
p_work_end = work_sub.add_parser("end", help="End the active local Brigade work session.")
122122
p_work_end.add_argument("--target", "-t", type=Path, default=Path("."), help="Repo or workspace for the session.")
123123
p_work_end.add_argument("--note", default=None, help="Optional closing note.")
124+
p_work_end.add_argument("--handoff", action="store_true", help="Write a Memory Handoff for the ended session.")
125+
p_work_end.add_argument(
126+
"--handoff-inbox",
127+
type=Path,
128+
default=None,
129+
help="Memory Handoff inbox. Defaults to configured dogfood inbox or .claude/memory-handoffs.",
130+
)
124131

125132
# run
126133
p_run = sub.add_parser("run", help="Run a bounded cross-model orchestration task.")
@@ -397,7 +404,12 @@ def main(argv=None) -> int:
397404
title = " ".join(args.title) if args.title else None
398405
return work_cmd.start(target=args.target, title=title, force=args.force)
399406
if args.work_command == "end":
400-
return work_cmd.end(target=args.target, note=args.note)
407+
return work_cmd.end(
408+
target=args.target,
409+
note=args.note,
410+
handoff=args.handoff,
411+
handoff_inbox=args.handoff_inbox,
412+
)
401413
parser.error(f"unknown work command: {args.work_command}")
402414
return 2
403415
if cmd == "run":

src/brigade/work_cmd.py

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from datetime import datetime, timezone
1010
from pathlib import Path
1111
from typing import Any
12+
from uuid import uuid4
1213

1314
from . import dogfood_cmd
1415

@@ -147,6 +148,95 @@ def _write_session_markdown(path: Path, *, title: str, payload: dict[str, Any],
147148
path.write_text("\n".join(lines) + "\n")
148149

149150

151+
def _handoff_inbox(target: Path, payload: dict[str, Any], override: Path | None) -> Path:
152+
if override is not None:
153+
return override.expanduser()
154+
dogfood = payload.get("end", {}).get("dogfood", {})
155+
configured = dogfood.get("handoff_inbox")
156+
if isinstance(configured, str) and configured:
157+
return Path(configured).expanduser()
158+
return target / ".claude" / "memory-handoffs"
159+
160+
161+
def _write_work_handoff(target: Path, session_dir: Path, payload: dict[str, Any], inbox: Path) -> Path:
162+
ended = payload.get("ended_at") or _now().isoformat()
163+
ended_slug = re.sub(r"[^0-9]", "", str(ended))[:12] or _now().strftime("%Y%m%d%H%M")
164+
title = payload.get("title") or payload.get("id") or "work-session"
165+
path = inbox / f"{ended_slug}-brigade-work-{_slug(str(title))}-{uuid4().hex[:6]}.md"
166+
end_snapshot = payload.get("end", {})
167+
git = end_snapshot.get("git", {})
168+
dogfood = end_snapshot.get("dogfood", {})
169+
dirty = git.get("dirty_files") if isinstance(git, dict) else []
170+
dirty_lines = "\n".join(f" - `{item}`" for item in dirty[:20]) if isinstance(dirty, list) else " - unavailable"
171+
latest = dogfood.get("latest_run") if isinstance(dogfood, dict) else None
172+
latest_line = "- latest run: none"
173+
if isinstance(latest, dict):
174+
latest_line = f"- latest run: `{latest.get('path')}` ({latest.get('status')})"
175+
next_step = dogfood.get("next") if isinstance(dogfood, dict) else None
176+
next_line = f"- next: {next_step}" if next_step else "- next: none extracted"
177+
note = payload.get("note") or ""
178+
document_content = f"""### Brigade work session: {payload.get('id')}
179+
- target: `{target}`
180+
- session artifacts: `{session_dir}`
181+
- branch: {git.get('branch') if isinstance(git, dict) else 'unknown'}
182+
- dirty files: {len(dirty) if isinstance(dirty, list) else 'unknown'}
183+
{latest_line}
184+
{next_line}
185+
"""
186+
if note:
187+
document_content += f"- note: {note}\n"
188+
body = f"""# Memory Handoff
189+
190+
## Type
191+
192+
workflow
193+
194+
## Title
195+
196+
Brigade work session ended: {_slug(str(title))}
197+
198+
## Summary
199+
200+
A Brigade work session was ended and local session artifacts were written. This handoff captures the session path, final git state, latest dogfood run, and extracted next step so the memory owner can route durable workflow context.
201+
202+
## Durable facts
203+
204+
- session: `{payload.get('id')}`
205+
- target: `{target}`
206+
- session artifacts: `{session_dir}`
207+
- status: {payload.get('status')}
208+
- started: {payload.get('started_at')}
209+
- ended: {payload.get('ended_at')}
210+
- note: {note or 'none'}
211+
- branch: {git.get('branch') if isinstance(git, dict) else 'unknown'}
212+
- dirty files:
213+
{dirty_lines}
214+
{latest_line}
215+
{next_line}
216+
217+
## Evidence
218+
219+
- session.json: `{session_dir / 'session.json'}`
220+
- start summary: `{session_dir / 'start.md'}`
221+
- end summary: `{session_dir / 'end.md'}`
222+
223+
## Recommended memory action
224+
225+
no-card
226+
227+
## Target document
228+
229+
.learnings/LEARNINGS.md
230+
231+
## Suggested document content
232+
233+
{document_content.strip()}
234+
"""
235+
inbox.mkdir(parents=True, exist_ok=True)
236+
path.write_text(body)
237+
return path
238+
239+
150240
def _print_dirty(lines: list[str], *, limit: int) -> None:
151241
print(f"dirty_files: {len(lines)}")
152242
for line in lines[:limit]:
@@ -188,7 +278,7 @@ def start(*, target: Path, title: str | None = None, force: bool = False) -> int
188278
return 0
189279

190280

191-
def end(*, target: Path, note: str | None = None) -> int:
281+
def end(*, target: Path, note: str | None = None, handoff: bool = False, handoff_inbox: Path | None = None) -> int:
192282
target = target.expanduser().resolve()
193283
if not target.is_dir():
194284
print(f"error: --target is not a directory: {target}", file=sys.stderr)
@@ -216,8 +306,15 @@ def end(*, target: Path, note: str | None = None) -> int:
216306
payload["end"] = _session_snapshot(target)
217307
_write_json(session_json, payload)
218308
_write_session_markdown(session_dir / "end.md", title="Brigade Work Session End", payload=payload, key="end")
309+
if handoff:
310+
inbox = _handoff_inbox(target, payload, handoff_inbox)
311+
handoff_path = _write_work_handoff(target, session_dir, payload, inbox)
312+
payload["handoff"] = str(handoff_path)
313+
_write_json(session_json, payload)
219314
current.unlink()
220315
print(f"session: {session_dir}")
316+
if handoff:
317+
print(f"handoff: {payload['handoff']}")
221318
print("status: ended")
222319
return 0
223320

tests/test_work_cmd.py

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,32 @@ def test_work_end_closes_active_session(tmp_path, monkeypatch, capsys):
126126
assert "done for now" in (session_dir / "end.md").read_text()
127127

128128

129+
def test_work_end_can_write_handoff(tmp_path, monkeypatch, capsys):
130+
_init_git_repo(tmp_path)
131+
times = iter(
132+
[
133+
datetime(2026, 5, 26, 12, 0, 0, tzinfo=timezone.utc),
134+
datetime(2026, 5, 26, 13, 0, 0, tzinfo=timezone.utc),
135+
]
136+
)
137+
monkeypatch.setattr(work_cmd, "_now", lambda: next(times))
138+
assert work_cmd.start(target=tmp_path, title="Build Work Loop") == 0
139+
140+
inbox = tmp_path / "handoffs"
141+
assert work_cmd.end(target=tmp_path, note="done for now", handoff=True, handoff_inbox=inbox) == 0
142+
out = capsys.readouterr().out
143+
assert "handoff:" in out
144+
handoffs = list(inbox.glob("*-brigade-work-build-work-loop-*.md"))
145+
assert len(handoffs) == 1
146+
handoff = handoffs[0].read_text()
147+
assert "# Memory Handoff" in handoff
148+
assert "Brigade work session ended" in handoff
149+
assert "done for now" in handoff
150+
session_dir = tmp_path / ".brigade" / "work" / "20260526-120000-build-work-loop"
151+
payload = json.loads((session_dir / "session.json").read_text())
152+
assert payload["handoff"] == str(handoffs[0])
153+
154+
129155
def test_work_end_reports_no_active_session(tmp_path, capsys):
130156
_init_git_repo(tmp_path)
131157

@@ -161,8 +187,31 @@ def fake_end(**kwargs):
161187
monkeypatch.setattr(work_cmd, "end", fake_end)
162188

163189
assert cli.main(["work", "start", "Build", "Loop", "--target", str(tmp_path), "--force"]) == 0
164-
assert cli.main(["work", "end", "--target", str(tmp_path), "--note", "done"]) == 0
190+
assert (
191+
cli.main(
192+
[
193+
"work",
194+
"end",
195+
"--target",
196+
str(tmp_path),
197+
"--note",
198+
"done",
199+
"--handoff",
200+
"--handoff-inbox",
201+
str(tmp_path / "handoffs"),
202+
]
203+
)
204+
== 0
205+
)
165206
assert seen == [
166207
("start", {"target": tmp_path, "title": "Build Loop", "force": True}),
167-
("end", {"target": tmp_path, "note": "done"}),
208+
(
209+
"end",
210+
{
211+
"target": tmp_path,
212+
"note": "done",
213+
"handoff": True,
214+
"handoff_inbox": tmp_path / "handoffs",
215+
},
216+
),
168217
]

0 commit comments

Comments
 (0)