Skip to content

Commit 1046482

Browse files
authored
Merge pull request #16 from escoffier-labs/feat/brigade-work-sessions
feat: add brigade work sessions
2 parents f0d472c + c5c5ff0 commit 1046482

8 files changed

Lines changed: 289 additions & 3 deletions

File tree

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,12 @@ memory/20[0-9][0-9]-[0-1][0-9]-[0-3][0-9].md
5959
# Review inbox: ambiguous handoffs awaiting human triage. Private.
6060
memory/handoff-inbox/
6161

62-
# brigade local state (logs, scrub cache, dogfood runs).
62+
# brigade local state (logs, scrub cache, dogfood runs, work sessions).
6363
.brigade/dogfood.toml
6464
.brigade/logs/
6565
.brigade/runs/
6666
.brigade/scrub-cache/
67+
.brigade/work/
6768
.solo-mise/logs/
6869
.solo-mise/scrub-cache/
6970
# <<< brigade gitignore block <<<

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2828
- `brigade runs latest` to show the newest run summary without copying a run path from `brigade runs list`.
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.
31+
- `brigade work start` and `brigade work end` to create local `.brigade/work/` session artifacts for normal daily work loops.
3132
- Roster-level and per-agent `timeout_seconds` controls for bounded CLI calls.
3233
- `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.
3334

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 start "next slice"
139+
brigade work end --note "tests passed"
138140
```
139141

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

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

159-
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.
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`.
160162

161163
Inspect a completed run without opening each JSON file:
162164

src/brigade/cli.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,13 @@ 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_start = work_sub.add_parser("start", help="Start a local Brigade work session.")
118+
p_work_start.add_argument("title", nargs="*", help="Optional session title.")
119+
p_work_start.add_argument("--target", "-t", type=Path, default=Path("."), help="Repo or workspace for the session.")
120+
p_work_start.add_argument("--force", action="store_true", help="Replace an existing active session pointer.")
121+
p_work_end = work_sub.add_parser("end", help="End the active local Brigade work session.")
122+
p_work_end.add_argument("--target", "-t", type=Path, default=Path("."), help="Repo or workspace for the session.")
123+
p_work_end.add_argument("--note", default=None, help="Optional closing note.")
117124

118125
# run
119126
p_run = sub.add_parser("run", help="Run a bounded cross-model orchestration task.")
@@ -386,6 +393,11 @@ def main(argv=None) -> int:
386393

387394
if args.work_command == "status":
388395
return work_cmd.status(target=args.target, limit=args.limit)
396+
if args.work_command == "start":
397+
title = " ".join(args.title) if args.title else None
398+
return work_cmd.start(target=args.target, title=title, force=args.force)
399+
if args.work_command == "end":
400+
return work_cmd.end(target=args.target, note=args.note)
389401
parser.error(f"unknown work command: {args.work_command}")
390402
return 2
391403
if cmd == "run":

src/brigade/install.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,11 +58,12 @@ def build_gitignore_block(selection: Selection) -> str:
5858
"# Review inbox: ambiguous handoffs awaiting human triage.",
5959
"memory/handoff-inbox/",
6060
"",
61-
"# brigade local state (logs, scrub cache, dogfood runs).",
61+
"# brigade local state (logs, scrub cache, dogfood runs, work sessions).",
6262
".brigade/dogfood.toml",
6363
".brigade/logs/",
6464
".brigade/runs/",
6565
".brigade/scrub-cache/",
66+
".brigade/work/",
6667
GITIGNORE_END,
6768
"",
6869
])

src/brigade/work_cmd.py

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
"""Daily work session helpers."""
22
from __future__ import annotations
33

4+
import json
5+
import re
46
import shutil
57
import subprocess
68
import sys
9+
from datetime import datetime, timezone
710
from pathlib import Path
11+
from typing import Any
812

913
from . import dogfood_cmd
1014

@@ -34,6 +38,115 @@ def _short(text: str, limit: int = 96) -> str:
3438
return rendered[: limit - 3].rstrip() + "..."
3539

3640

41+
def _now() -> datetime:
42+
return datetime.now(timezone.utc)
43+
44+
45+
def _slug(text: str) -> str:
46+
value = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
47+
return value[:48].strip("-") or "work-session"
48+
49+
50+
def _work_root(target: Path) -> Path:
51+
return target / ".brigade" / "work"
52+
53+
54+
def _current_path(target: Path) -> Path:
55+
return _work_root(target) / "current"
56+
57+
58+
def _git_snapshot(target: Path) -> dict[str, Any]:
59+
repo_root = _git_value(target, "rev-parse", "--show-toplevel")
60+
if repo_root is None:
61+
return {"available": False, "dirty_files": []}
62+
branch = _git_value(target, "branch", "--show-current")
63+
if branch is None:
64+
branch = _git_value(target, "rev-parse", "--short", "HEAD") or "unknown"
65+
branch = f"detached:{branch}"
66+
status_out = _git_value(target, "status", "--short") or ""
67+
return {
68+
"available": True,
69+
"repo": repo_root,
70+
"branch": branch,
71+
"dirty_files": status_out.splitlines(),
72+
}
73+
74+
75+
def _dogfood_snapshot(target: Path) -> dict[str, Any]:
76+
try:
77+
effective_target, artifacts_dir, cfg = dogfood_cmd._load_effective_paths(target)
78+
except (FileNotFoundError, ValueError) as exc:
79+
return {"ready": False, "error": str(exc)}
80+
latest = dogfood_cmd._latest_run(artifacts_dir)
81+
snapshot: dict[str, Any] = {
82+
"ready": dogfood_cmd.config_path(target).exists() and shutil.which("codex") is not None,
83+
"config": str(dogfood_cmd.config_path(target)),
84+
"target": str(effective_target),
85+
"artifacts_dir": str(artifacts_dir),
86+
"handoff_inbox": str(cfg.handoff_inbox) if cfg and cfg.handoff_inbox is not None else None,
87+
}
88+
if latest is None:
89+
snapshot["latest_run"] = None
90+
snapshot["next"] = None
91+
return snapshot
92+
latest_path, latest_meta = latest
93+
snapshot["latest_run"] = {
94+
"path": str(latest_path),
95+
"started_at": latest_meta.get("started_at"),
96+
"status": latest_meta.get("status"),
97+
"task": latest_meta.get("task"),
98+
}
99+
snapshot["next"] = dogfood_cmd.extract_next_step(dogfood_cmd._read_final(latest_path))
100+
return snapshot
101+
102+
103+
def _session_snapshot(target: Path) -> dict[str, Any]:
104+
return {
105+
"git": _git_snapshot(target),
106+
"dogfood": _dogfood_snapshot(target),
107+
}
108+
109+
110+
def _write_json(path: Path, payload: dict[str, Any]) -> None:
111+
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
112+
113+
114+
def _write_session_markdown(path: Path, *, title: str, payload: dict[str, Any], key: str) -> None:
115+
snapshot = payload[key]
116+
git = snapshot.get("git", {})
117+
dogfood = snapshot.get("dogfood", {})
118+
lines = [
119+
f"# {title}",
120+
"",
121+
f"- Session: {payload['id']}",
122+
f"- Target: {payload['target']}",
123+
f"- Started: {payload['started_at']}",
124+
]
125+
if payload.get("ended_at"):
126+
lines.append(f"- Ended: {payload['ended_at']}")
127+
if payload.get("title"):
128+
lines.append(f"- Title: {payload['title']}")
129+
if payload.get("note"):
130+
lines.append(f"- Note: {payload['note']}")
131+
lines.extend(["", "## Git", ""])
132+
if git.get("available"):
133+
lines.append(f"- Branch: {git.get('branch')}")
134+
dirty = git.get("dirty_files") or []
135+
lines.append(f"- Dirty files: {len(dirty)}")
136+
for item in dirty[:20]:
137+
lines.append(f" - `{item}`")
138+
else:
139+
lines.append("- unavailable")
140+
lines.extend(["", "## Dogfood", ""])
141+
lines.append(f"- Ready: {dogfood.get('ready')}")
142+
if dogfood.get("latest_run"):
143+
latest = dogfood["latest_run"]
144+
lines.append(f"- Latest run: {latest.get('started_at')} [{latest.get('status')}] {latest.get('path')}")
145+
if dogfood.get("next"):
146+
lines.append(f"- Next: {dogfood['next']}")
147+
path.write_text("\n".join(lines) + "\n")
148+
149+
37150
def _print_dirty(lines: list[str], *, limit: int) -> None:
38151
print(f"dirty_files: {len(lines)}")
39152
for line in lines[:limit]:
@@ -43,6 +156,72 @@ def _print_dirty(lines: list[str], *, limit: int) -> None:
43156
print(f" ... {remaining} more")
44157

45158

159+
def start(*, target: Path, title: str | None = None, force: bool = False) -> int:
160+
target = target.expanduser().resolve()
161+
if not target.is_dir():
162+
print(f"error: --target is not a directory: {target}", file=sys.stderr)
163+
return 2
164+
165+
root = _work_root(target)
166+
current = _current_path(target)
167+
if current.exists() and not force:
168+
print(f"error: work session already active: {current.read_text().strip()}", file=sys.stderr)
169+
return 2
170+
171+
started = _now()
172+
session_id = f"{started.strftime('%Y%m%d-%H%M%S')}-{_slug(title or 'work-session')}"
173+
session_dir = root / session_id
174+
session_dir.mkdir(parents=True, exist_ok=False)
175+
payload: dict[str, Any] = {
176+
"id": session_id,
177+
"title": title,
178+
"target": str(target),
179+
"status": "active",
180+
"started_at": started.isoformat(),
181+
"start": _session_snapshot(target),
182+
}
183+
_write_json(session_dir / "session.json", payload)
184+
_write_session_markdown(session_dir / "start.md", title="Brigade Work Session Start", payload=payload, key="start")
185+
current.write_text(session_id + "\n")
186+
print(f"session: {session_dir}")
187+
print(f"status: active")
188+
return 0
189+
190+
191+
def end(*, target: Path, note: str | None = None) -> int:
192+
target = target.expanduser().resolve()
193+
if not target.is_dir():
194+
print(f"error: --target is not a directory: {target}", file=sys.stderr)
195+
return 2
196+
197+
current = _current_path(target)
198+
if not current.exists():
199+
print(f"error: no active work session in {_work_root(target)}", file=sys.stderr)
200+
return 1
201+
session_id = current.read_text().strip()
202+
session_dir = _work_root(target) / session_id
203+
session_json = session_dir / "session.json"
204+
try:
205+
payload = json.loads(session_json.read_text())
206+
except (OSError, json.JSONDecodeError) as exc:
207+
print(f"error: invalid active work session: {exc}", file=sys.stderr)
208+
return 2
209+
if not isinstance(payload, dict):
210+
print("error: invalid active work session: session.json must contain an object", file=sys.stderr)
211+
return 2
212+
213+
payload["status"] = "ended"
214+
payload["ended_at"] = _now().isoformat()
215+
payload["note"] = note
216+
payload["end"] = _session_snapshot(target)
217+
_write_json(session_json, payload)
218+
_write_session_markdown(session_dir / "end.md", title="Brigade Work Session End", payload=payload, key="end")
219+
current.unlink()
220+
print(f"session: {session_dir}")
221+
print("status: ended")
222+
return 0
223+
224+
46225
def status(*, target: Path, limit: int = 12) -> int:
47226
if limit < 1:
48227
print("error: --limit must be a positive integer", file=sys.stderr)

tests/test_gitignore.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ def test_init_creates_gitignore_when_missing(tmp_target: Path):
2727
assert "memory/handoff-inbox/" in gi
2828
assert ".brigade/dogfood.toml" in gi
2929
assert ".brigade/runs/" in gi
30+
assert ".brigade/work/" in gi
3031

3132

3233
def test_init_appends_block_to_existing_gitignore(tmp_target: Path):
@@ -106,6 +107,7 @@ def test_gitignore_block_includes_claude_section_when_selected():
106107
assert "!.claude/memory-handoffs/TEMPLATE.md" in block
107108
assert ".brigade/dogfood.toml" in block
108109
assert ".brigade/runs/" in block
110+
assert ".brigade/work/" in block
109111
assert ".codex/memory-handoffs" not in block
110112

111113

@@ -135,3 +137,4 @@ def test_install_writes_gitignore_block(tmp_path):
135137
assert ".codex/memory-handoffs/*" in gi
136138
assert ".brigade/dogfood.toml" in gi
137139
assert ".brigade/runs/" in gi
140+
assert ".brigade/work/" in gi

0 commit comments

Comments
 (0)