Skip to content

Commit 103bb40

Browse files
authored
feat: add daily work brief
1 parent 2328c94 commit 103bb40

7 files changed

Lines changed: 316 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4242
- `brigade work next` to resolve the next daily task without inspecting artifacts, plus `brigade work run` now uses the latest extracted next step when no task is passed.
4343
- `brigade work next --json` to expose the resolved daily task, active session, dogfood snapshot, and suggested command to wrappers.
4444
- `brigade work bootstrap` to initialize and verify the dogfood-backed daily work loop in one command.
45+
- `brigade work brief` and `brigade work brief --json` as a start-of-day entrypoint with git state, latest sessions, latest dogfood run, resolved next task, and suggested command.
4546
- `brigade work note` to append timestamped checkpoints to the active work session without ending it.
4647
- `brigade work doctor` to check dogfood config, Codex availability, local artifact paths, handoff inbox, ignore coverage, and latest run context for the daily work loop.
4748
- Workspace installs now include `.brigade/memory-care.example.json` as a scanner wiring contract for memory-care decay output.
@@ -53,6 +54,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
5354
- Dogfood handoff defaults now use `.codex/memory-handoffs/` for new Codex-driven local configs while preserving explicit configured inbox paths such as `.claude/memory-handoffs/`.
5455
- Bootstrap truncation is now treated as a hard doctor failure to prevent by moving durable detail into memory cards before agents load context.
5556
- Dogfood runs now default to a 600 second per-agent timeout for practical daily repo reviews.
57+
- Dogfood next-step extraction now handles markdown `## Next` sections and can fall back to `summary.md` when `final.txt` does not contain a next-step label.
5658
- The managed gitignore block now treats `.brigade/dogfood.toml` and `.brigade/runs/` as local state.
5759
- Live smoke docs now keep Codex agent execution in a trusted repo cwd while writing temporary roster, artifacts, and handoff output under `/tmp`.
5860
- Handoff write failures now preserve final run artifacts, print the final answer, return nonzero, and mark `run.json` as `handoff-failed`.

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,8 @@ brigade work bootstrap
140140
brigade work status
141141
brigade work doctor
142142
brigade work resume
143+
brigade work brief
144+
brigade work brief --json
143145
brigade work next
144146
brigade work next --json
145147
brigade work run
@@ -171,7 +173,7 @@ CLI runs write artifacts by default under `.brigade/runs/<id>` below `--cwd`; do
171173

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

174-
Use `brigade work bootstrap` once in a repo to initialize the daily loop: it writes or verifies `.brigade/dogfood.toml`, creates local run and work artifact directories, creates the configured handoff inbox, updates the managed `.gitignore` block, and reports whether the repo is ready to run. 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 doctor` to check whether the repo is ready for the daily loop: dogfood config, Codex CLI, local artifact paths, handoff inbox, ignore coverage, and latest run context. 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. Use `brigade work next` when you only need the task Brigade would run next, or `brigade work next --json` for wrappers that want the active session, dogfood snapshot, resolved task, and suggested command as structured output. `brigade work run` is the one-command daily loop: it starts a work session, uses the latest extracted dogfood next step when no task is provided, 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 resolved next step, `--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 note "checkpoint"` appends a timestamped note to the active session without ending it. `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`.
176+
Use `brigade work bootstrap` once in a repo to initialize the daily loop: it writes or verifies `.brigade/dogfood.toml`, creates local run and work artifact directories, creates the configured handoff inbox, updates the managed `.gitignore` block, and reports whether the repo is ready to run. Use `brigade work brief` as the normal start-of-day command. It shows branch and dirty-file state, active and latest work sessions, latest dogfood run, extracted next task, recent sessions, and the command to continue; add `--json` for wrappers. 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 doctor` to check whether the repo is ready for the daily loop: dogfood config, Codex CLI, local artifact paths, handoff inbox, ignore coverage, and latest run context. 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. Use `brigade work next` when you only need the task Brigade would run next, or `brigade work next --json` for wrappers that want the active session, dogfood snapshot, resolved task, and suggested command as structured output. `brigade work run` is the one-command daily loop: it starts a work session, uses the latest extracted dogfood next step when no task is provided, 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 resolved next step, `--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 note "checkpoint"` appends a timestamped note to the active session without ending it. `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`.
175177

176178
Memory and bootstrap readiness are part of the same operating-system health story. `brigade doctor` checks installed bootstrap files against built-in hard byte budgets so overgrown files fail before agents load truncated context. It also checks `memory/cards/*.md` budgets, verifies that `MEMORY.md` card links resolve under `memory/cards/`, and reports memory-care freshness from `memory/cards/decay/scan-latest.json`. Workspace installs include `.brigade/memory-care.example.json` as a scanner wiring contract for whatever scheduler or memory owner produces the decay files. Missing memory-care decay state is advisory for fresh installs, but corrupt scan or refresh-queue JSON fails once the loop is wired. Bootstrap truncation is treated as a hard failure to prevent, not a cosmetic warning; keep cards atomic and bootstrap files as slim indexes.
177179

src/brigade/cli.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,10 @@ def _build_parser() -> argparse.ArgumentParser:
132132
p_work_bootstrap.add_argument("--no-gitignore", action="store_true", help="Do not update the target .gitignore.")
133133
p_work_resume = work_sub.add_parser("resume", help="Show the current work handoff point and next command.")
134134
p_work_resume.add_argument("--target", "-t", type=Path, default=Path("."), help="Repo or workspace to inspect.")
135+
p_work_brief = work_sub.add_parser("brief", help="Show the daily work brief and suggested next command.")
136+
p_work_brief.add_argument("--target", "-t", type=Path, default=Path("."), help="Repo or workspace to inspect.")
137+
p_work_brief.add_argument("--limit", type=int, default=3, help="Maximum recent sessions to include.")
138+
p_work_brief.add_argument("--json", action="store_true", help="Print machine-readable JSON.")
135139
p_work_next = work_sub.add_parser("next", help="Show the next daily work task and suggested command.")
136140
p_work_next.add_argument("--target", "-t", type=Path, default=Path("."), help="Repo or workspace to inspect.")
137141
p_work_next.add_argument("--json", action="store_true", help="Print machine-readable JSON.")
@@ -472,6 +476,8 @@ def main(argv=None) -> int:
472476
)
473477
if args.work_command == "resume":
474478
return work_cmd.resume(target=args.target)
479+
if args.work_command == "brief":
480+
return work_cmd.brief(target=args.target, limit=args.limit, json_output=args.json)
475481
if args.work_command == "next":
476482
return work_cmd.next(target=args.target, json_output=args.json)
477483
if args.work_command == "list":

src/brigade/dogfood_cmd.py

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

44
import ast
55
import json
6+
import re
67
import shutil
78
import subprocess
89
import sys
@@ -224,31 +225,67 @@ def _read_final(run_dir: Path) -> str:
224225
return ""
225226

226227

228+
def _read_summary(run_dir: Path) -> str:
229+
try:
230+
return (run_dir / "summary.md").read_text().strip()
231+
except OSError:
232+
return ""
233+
234+
235+
def _clean_next_candidate(line: str) -> str:
236+
cleaned = line.strip()
237+
cleaned = re.sub(r"^\s{0,3}#{1,6}\s+", "", cleaned)
238+
cleaned = re.sub(r"^\s{0,3}[-*+]\s+", "", cleaned)
239+
cleaned = cleaned.strip().strip("*").strip()
240+
return cleaned
241+
242+
243+
def _collect_next_block(lines: list[str], start: int) -> str | None:
244+
collected: list[str] = []
245+
for follow in lines[start:]:
246+
follow_stripped = follow.strip()
247+
if not follow_stripped:
248+
if collected:
249+
break
250+
continue
251+
if collected and re.match(r"^\s{0,3}#{1,6}\s+", follow_stripped):
252+
break
253+
if collected and follow_stripped.endswith(":") and not follow_stripped.startswith(("-", "*", "+")):
254+
break
255+
collected.append(follow_stripped)
256+
rendered = "\n".join(item for item in collected if item).strip()
257+
if _clean_next_candidate(rendered).lower() in {"(none extracted)", "none", "n/a"}:
258+
return None
259+
return rendered or None
260+
261+
227262
def extract_next_step(final_text: str) -> str | None:
228263
lines = final_text.splitlines()
229264
for index, line in enumerate(lines):
230-
stripped = line.strip().strip("*")
265+
stripped = _clean_next_candidate(line)
231266
lowered = stripped.lower()
267+
if lowered == "next":
268+
return _collect_next_block(lines, index + 1)
232269
for label in NEXT_LABELS:
233270
if not lowered.startswith(label):
234271
continue
235272
_, _, after = stripped.partition(":")
236273
if after.strip():
237274
return after.strip()
238-
collected: list[str] = []
239-
for follow in lines[index + 1 :]:
240-
follow_stripped = follow.strip()
241-
if not follow_stripped:
242-
if collected:
243-
break
244-
continue
245-
if collected and follow_stripped.endswith(":") and not follow_stripped.startswith(("-", "*")):
246-
break
247-
collected.append(follow_stripped)
248-
return "\n".join(collected).strip() or None
275+
return _collect_next_block(lines, index + 1)
249276
return None
250277

251278

279+
def extract_next_step_from_run(run_dir: Path) -> tuple[str | None, str | None]:
280+
final_next = extract_next_step(_read_final(run_dir))
281+
if final_next:
282+
return final_next, "final"
283+
summary_next = extract_next_step(_read_summary(run_dir))
284+
if summary_next:
285+
return summary_next, "summary"
286+
return None, None
287+
288+
252289
def _write_summary(run_dir: Path) -> None:
253290
run_meta = _read_run_json(run_dir)
254291
if run_meta is None:
@@ -290,8 +327,7 @@ def next_step(*, target: Path) -> int:
290327
print(f"error: no runs found in {artifacts_dir}", file=sys.stderr)
291328
return 1
292329
run_dir, _ = latest_run
293-
final_text = _read_final(run_dir)
294-
extracted = extract_next_step(final_text)
330+
extracted, _ = extract_next_step_from_run(run_dir)
295331
if not extracted:
296332
print(f"error: no next step found in {run_dir / 'final.txt'}", file=sys.stderr)
297333
return 1
@@ -368,7 +404,7 @@ def status(*, target: Path) -> int:
368404
)
369405
if task:
370406
_setting_line("latest_task", task)
371-
next_step_text = extract_next_step(_read_final(latest_path))
407+
next_step_text, _ = extract_next_step_from_run(latest_path)
372408
if next_step_text:
373409
_setting_line("latest_next", " ".join(next_step_text.split()))
374410
else:

src/brigade/work_cmd.py

Lines changed: 142 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,13 +102,15 @@ def _dogfood_snapshot(target: Path) -> dict[str, Any]:
102102
snapshot["next"] = None
103103
return snapshot
104104
latest_path, latest_meta = latest
105+
next_step, next_source = dogfood_cmd.extract_next_step_from_run(latest_path)
105106
snapshot["latest_run"] = {
106107
"path": str(latest_path),
107108
"started_at": latest_meta.get("started_at"),
108109
"status": latest_meta.get("status"),
109110
"task": latest_meta.get("task"),
110111
}
111-
snapshot["next"] = dogfood_cmd.extract_next_step(dogfood_cmd._read_final(latest_path))
112+
snapshot["next"] = next_step
113+
snapshot["next_source"] = next_source
112114
return snapshot
113115

114116

@@ -213,6 +215,30 @@ def _next_step(snapshot: dict[str, Any]) -> str | None:
213215
return None
214216

215217

218+
def _session_info(path: Path, payload: dict[str, Any]) -> dict[str, Any]:
219+
snapshot = _snapshot(payload)
220+
notes = payload.get("notes")
221+
latest_note = None
222+
if isinstance(notes, list) and notes:
223+
latest = notes[-1]
224+
if isinstance(latest, dict) and latest.get("text"):
225+
latest_note = latest["text"]
226+
return {
227+
"path": str(path),
228+
"id": payload.get("id", path.name),
229+
"status": payload.get("status", "unknown"),
230+
"title": payload.get("title"),
231+
"started_at": payload.get("started_at"),
232+
"ended_at": payload.get("ended_at"),
233+
"note": payload.get("note"),
234+
"latest_note": latest_note,
235+
"handoff": payload.get("handoff"),
236+
"branch": _branch(snapshot),
237+
"dirty_files": _dirty_count(snapshot),
238+
"next": _next_step(snapshot),
239+
}
240+
241+
216242
def _resolve_next_task(target: Path) -> dict[str, Any]:
217243
dogfood = _dogfood_snapshot(target)
218244
next_step = dogfood.get("next") if isinstance(dogfood.get("next"), str) else None
@@ -455,6 +481,37 @@ def _next_payload(target: Path) -> dict[str, Any]:
455481
}
456482

457483

484+
def _suggested_command(active: dict[str, Any] | None, next_text: object, source: object) -> str:
485+
if active is not None:
486+
return 'brigade work end --note "..." --handoff'
487+
if isinstance(next_text, str) and next_text.strip() and source != "default_review":
488+
return f"brigade work run {shlex.quote(next_text.strip())}"
489+
return "brigade work run"
490+
491+
492+
def _brief_payload(target: Path, *, limit: int = 3) -> dict[str, Any]:
493+
target = target.expanduser().resolve()
494+
active = _active_session_info(target)
495+
sessions, skipped = _collect_sessions(_work_root(target))
496+
latest_session = _session_info(sessions[0][0], sessions[0][1]) if sessions else None
497+
recent_sessions = [_session_info(path, payload) for path, payload in sessions[:limit]]
498+
resolved = _resolve_next_task(target)
499+
git = _git_snapshot(target)
500+
suggested = _suggested_command(active, resolved["task"], resolved["source"])
501+
return {
502+
"target": str(target),
503+
"git": git,
504+
"active_session": active,
505+
"latest_session": latest_session,
506+
"recent_sessions": recent_sessions,
507+
"skipped_sessions": skipped,
508+
"dogfood": resolved["dogfood"],
509+
"next_source": resolved["source"],
510+
"next": str(resolved["task"]),
511+
"suggested_command": suggested,
512+
}
513+
514+
458515
def _print_bootstrap_line(level: str, name: str, detail: object) -> None:
459516
print(f"[{level}] {name}: {detail}")
460517

@@ -807,6 +864,90 @@ def resume(*, target: Path) -> int:
807864
return 0
808865

809866

867+
def brief(*, target: Path, limit: int = 3, json_output: bool = False) -> int:
868+
if limit < 1:
869+
print("error: --limit must be a positive integer", file=sys.stderr)
870+
return 2
871+
872+
target = target.expanduser().resolve()
873+
if not target.is_dir():
874+
print(f"error: --target is not a directory: {target}", file=sys.stderr)
875+
return 2
876+
877+
payload = _brief_payload(target, limit=limit)
878+
if json_output:
879+
print(json.dumps(payload, indent=2, sort_keys=True))
880+
return 0
881+
882+
print(f"work brief: {target}")
883+
git = payload["git"]
884+
if isinstance(git, dict) and git.get("available"):
885+
print(f"branch: {git.get('branch')}")
886+
dirty = git.get("dirty_files") if isinstance(git.get("dirty_files"), list) else []
887+
print(f"dirty_files: {len(dirty)}")
888+
for item in dirty[:8]:
889+
print(f" {item}")
890+
if len(dirty) > 8:
891+
print(f" ... {len(dirty) - 8} more")
892+
else:
893+
print("git: unavailable")
894+
895+
active = payload["active_session"]
896+
if isinstance(active, dict):
897+
if active.get("valid"):
898+
print(f"active_session: {active.get('path')}")
899+
if active.get("title"):
900+
print(f"active_session_title: {_short(str(active['title']))}")
901+
else:
902+
print(f"active_session: invalid ({active.get('path')})")
903+
else:
904+
print("active_session: none")
905+
906+
latest_session = payload["latest_session"]
907+
if isinstance(latest_session, dict):
908+
print(f"latest_session: {latest_session.get('path')}")
909+
if latest_session.get("title"):
910+
print(f"latest_session_title: {_short(str(latest_session['title']))}")
911+
if latest_session.get("note"):
912+
print(f"latest_session_note: {_short(str(latest_session['note']))}")
913+
if latest_session.get("handoff"):
914+
print(f"latest_session_handoff: {latest_session['handoff']}")
915+
else:
916+
print(f"latest_session: none ({_work_root(target)})")
917+
918+
dogfood = payload["dogfood"]
919+
print(f"dogfood_ready: {dogfood.get('ready')}")
920+
if dogfood.get("error"):
921+
print(f"dogfood_error: {dogfood['error']}")
922+
latest_run = dogfood.get("latest_run")
923+
if isinstance(latest_run, dict):
924+
print(
925+
"latest_run: "
926+
f"{latest_run.get('started_at', '')} "
927+
f"[{latest_run.get('status', 'unknown')}] {latest_run.get('path')}"
928+
)
929+
if latest_run.get("task"):
930+
print(f"latest_task: {_short(str(latest_run['task']))}")
931+
else:
932+
print("latest_run: none")
933+
934+
print(f"next_source: {payload['next_source']}")
935+
print(f"next: {_short(str(payload['next']))}")
936+
print(f"suggested_command: {payload['suggested_command']}")
937+
938+
recent = payload["recent_sessions"]
939+
if isinstance(recent, list) and recent:
940+
print("recent_sessions:")
941+
for item in recent:
942+
if not isinstance(item, dict):
943+
continue
944+
title = item.get("title") or item.get("id")
945+
print(f" - {item.get('started_at')} [{item.get('status')}] {_short(str(title))}")
946+
if payload.get("skipped_sessions"):
947+
print(f"skipped_sessions: {payload['skipped_sessions']}", file=sys.stderr)
948+
return 0
949+
950+
810951
def next(*, target: Path, json_output: bool = False) -> int:
811952
target = target.expanduser().resolve()
812953
if not target.is_dir():

0 commit comments

Comments
 (0)