Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `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.
- `brigade work next --json` to expose the resolved daily task, active session, dogfood snapshot, and suggested command to wrappers.
- `brigade work bootstrap` to initialize and verify the dogfood-backed daily work loop in one command.
- `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.
- `brigade work note` to append timestamped checkpoints to the active work session without ending it.
- `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.
- Workspace installs now include `.brigade/memory-care.example.json` as a scanner wiring contract for memory-care decay output.
Expand All @@ -53,6 +54,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- 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/`.
- Bootstrap truncation is now treated as a hard doctor failure to prevent by moving durable detail into memory cards before agents load context.
- Dogfood runs now default to a 600 second per-agent timeout for practical daily repo reviews.
- 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.
- The managed gitignore block now treats `.brigade/dogfood.toml` and `.brigade/runs/` as local state.
- Live smoke docs now keep Codex agent execution in a trusted repo cwd while writing temporary roster, artifacts, and handoff output under `/tmp`.
- Handoff write failures now preserve final run artifacts, print the final answer, return nonzero, and mark `run.json` as `handoff-failed`.
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,8 @@ brigade work bootstrap
brigade work status
brigade work doctor
brigade work resume
brigade work brief
brigade work brief --json
brigade work next
brigade work next --json
brigade work run
Expand Down Expand Up @@ -171,7 +173,7 @@ CLI runs write artifacts by default under `.brigade/runs/<id>` below `--cwd`; do

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

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

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.

Expand Down
6 changes: 6 additions & 0 deletions src/brigade/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,10 @@ def _build_parser() -> argparse.ArgumentParser:
p_work_bootstrap.add_argument("--no-gitignore", action="store_true", help="Do not update the target .gitignore.")
p_work_resume = work_sub.add_parser("resume", help="Show the current work handoff point and next command.")
p_work_resume.add_argument("--target", "-t", type=Path, default=Path("."), help="Repo or workspace to inspect.")
p_work_brief = work_sub.add_parser("brief", help="Show the daily work brief and suggested next command.")
p_work_brief.add_argument("--target", "-t", type=Path, default=Path("."), help="Repo or workspace to inspect.")
p_work_brief.add_argument("--limit", type=int, default=3, help="Maximum recent sessions to include.")
p_work_brief.add_argument("--json", action="store_true", help="Print machine-readable JSON.")
p_work_next = work_sub.add_parser("next", help="Show the next daily work task and suggested command.")
p_work_next.add_argument("--target", "-t", type=Path, default=Path("."), help="Repo or workspace to inspect.")
p_work_next.add_argument("--json", action="store_true", help="Print machine-readable JSON.")
Expand Down Expand Up @@ -472,6 +476,8 @@ def main(argv=None) -> int:
)
if args.work_command == "resume":
return work_cmd.resume(target=args.target)
if args.work_command == "brief":
return work_cmd.brief(target=args.target, limit=args.limit, json_output=args.json)
if args.work_command == "next":
return work_cmd.next(target=args.target, json_output=args.json)
if args.work_command == "list":
Expand Down
66 changes: 51 additions & 15 deletions src/brigade/dogfood_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import ast
import json
import re
import shutil
import subprocess
import sys
Expand Down Expand Up @@ -224,31 +225,67 @@ def _read_final(run_dir: Path) -> str:
return ""


def _read_summary(run_dir: Path) -> str:
try:
return (run_dir / "summary.md").read_text().strip()
except OSError:
return ""


def _clean_next_candidate(line: str) -> str:
cleaned = line.strip()
cleaned = re.sub(r"^\s{0,3}#{1,6}\s+", "", cleaned)
cleaned = re.sub(r"^\s{0,3}[-*+]\s+", "", cleaned)
cleaned = cleaned.strip().strip("*").strip()
return cleaned


def _collect_next_block(lines: list[str], start: int) -> str | None:
collected: list[str] = []
for follow in lines[start:]:
follow_stripped = follow.strip()
if not follow_stripped:
if collected:
break
continue
if collected and re.match(r"^\s{0,3}#{1,6}\s+", follow_stripped):
break
if collected and follow_stripped.endswith(":") and not follow_stripped.startswith(("-", "*", "+")):
break
collected.append(follow_stripped)
rendered = "\n".join(item for item in collected if item).strip()
if _clean_next_candidate(rendered).lower() in {"(none extracted)", "none", "n/a"}:
return None
return rendered or None


def extract_next_step(final_text: str) -> str | None:
lines = final_text.splitlines()
for index, line in enumerate(lines):
stripped = line.strip().strip("*")
stripped = _clean_next_candidate(line)
lowered = stripped.lower()
if lowered == "next":
return _collect_next_block(lines, index + 1)
for label in NEXT_LABELS:
if not lowered.startswith(label):
continue
_, _, after = stripped.partition(":")
if after.strip():
return after.strip()
collected: list[str] = []
for follow in lines[index + 1 :]:
follow_stripped = follow.strip()
if not follow_stripped:
if collected:
break
continue
if collected and follow_stripped.endswith(":") and not follow_stripped.startswith(("-", "*")):
break
collected.append(follow_stripped)
return "\n".join(collected).strip() or None
return _collect_next_block(lines, index + 1)
return None


def extract_next_step_from_run(run_dir: Path) -> tuple[str | None, str | None]:
final_next = extract_next_step(_read_final(run_dir))
if final_next:
return final_next, "final"
summary_next = extract_next_step(_read_summary(run_dir))
if summary_next:
return summary_next, "summary"
return None, None


def _write_summary(run_dir: Path) -> None:
run_meta = _read_run_json(run_dir)
if run_meta is None:
Expand Down Expand Up @@ -290,8 +327,7 @@ def next_step(*, target: Path) -> int:
print(f"error: no runs found in {artifacts_dir}", file=sys.stderr)
return 1
run_dir, _ = latest_run
final_text = _read_final(run_dir)
extracted = extract_next_step(final_text)
extracted, _ = extract_next_step_from_run(run_dir)
if not extracted:
print(f"error: no next step found in {run_dir / 'final.txt'}", file=sys.stderr)
return 1
Expand Down Expand Up @@ -368,7 +404,7 @@ def status(*, target: Path) -> int:
)
if task:
_setting_line("latest_task", task)
next_step_text = extract_next_step(_read_final(latest_path))
next_step_text, _ = extract_next_step_from_run(latest_path)
if next_step_text:
_setting_line("latest_next", " ".join(next_step_text.split()))
else:
Expand Down
143 changes: 142 additions & 1 deletion src/brigade/work_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,13 +102,15 @@ def _dogfood_snapshot(target: Path) -> dict[str, Any]:
snapshot["next"] = None
return snapshot
latest_path, latest_meta = latest
next_step, next_source = dogfood_cmd.extract_next_step_from_run(latest_path)
snapshot["latest_run"] = {
"path": str(latest_path),
"started_at": latest_meta.get("started_at"),
"status": latest_meta.get("status"),
"task": latest_meta.get("task"),
}
snapshot["next"] = dogfood_cmd.extract_next_step(dogfood_cmd._read_final(latest_path))
snapshot["next"] = next_step
snapshot["next_source"] = next_source
return snapshot


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


def _session_info(path: Path, payload: dict[str, Any]) -> dict[str, Any]:
snapshot = _snapshot(payload)
notes = payload.get("notes")
latest_note = None
if isinstance(notes, list) and notes:
latest = notes[-1]
if isinstance(latest, dict) and latest.get("text"):
latest_note = latest["text"]
return {
"path": str(path),
"id": payload.get("id", path.name),
"status": payload.get("status", "unknown"),
"title": payload.get("title"),
"started_at": payload.get("started_at"),
"ended_at": payload.get("ended_at"),
"note": payload.get("note"),
"latest_note": latest_note,
"handoff": payload.get("handoff"),
"branch": _branch(snapshot),
"dirty_files": _dirty_count(snapshot),
"next": _next_step(snapshot),
}


def _resolve_next_task(target: Path) -> dict[str, Any]:
dogfood = _dogfood_snapshot(target)
next_step = dogfood.get("next") if isinstance(dogfood.get("next"), str) else None
Expand Down Expand Up @@ -455,6 +481,37 @@ def _next_payload(target: Path) -> dict[str, Any]:
}


def _suggested_command(active: dict[str, Any] | None, next_text: object, source: object) -> str:
if active is not None:
return 'brigade work end --note "..." --handoff'
if isinstance(next_text, str) and next_text.strip() and source != "default_review":
return f"brigade work run {shlex.quote(next_text.strip())}"
return "brigade work run"


def _brief_payload(target: Path, *, limit: int = 3) -> dict[str, Any]:
target = target.expanduser().resolve()
active = _active_session_info(target)
sessions, skipped = _collect_sessions(_work_root(target))
latest_session = _session_info(sessions[0][0], sessions[0][1]) if sessions else None
recent_sessions = [_session_info(path, payload) for path, payload in sessions[:limit]]
resolved = _resolve_next_task(target)
git = _git_snapshot(target)
suggested = _suggested_command(active, resolved["task"], resolved["source"])
return {
"target": str(target),
"git": git,
"active_session": active,
"latest_session": latest_session,
"recent_sessions": recent_sessions,
"skipped_sessions": skipped,
"dogfood": resolved["dogfood"],
"next_source": resolved["source"],
"next": str(resolved["task"]),
"suggested_command": suggested,
}


def _print_bootstrap_line(level: str, name: str, detail: object) -> None:
print(f"[{level}] {name}: {detail}")

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


def brief(*, target: Path, limit: int = 3, json_output: bool = False) -> int:
if limit < 1:
print("error: --limit must be a positive integer", file=sys.stderr)
return 2

target = target.expanduser().resolve()
if not target.is_dir():
print(f"error: --target is not a directory: {target}", file=sys.stderr)
return 2

payload = _brief_payload(target, limit=limit)
if json_output:
print(json.dumps(payload, indent=2, sort_keys=True))
return 0

print(f"work brief: {target}")
git = payload["git"]
if isinstance(git, dict) and git.get("available"):
print(f"branch: {git.get('branch')}")
dirty = git.get("dirty_files") if isinstance(git.get("dirty_files"), list) else []
print(f"dirty_files: {len(dirty)}")
for item in dirty[:8]:
print(f" {item}")
if len(dirty) > 8:
print(f" ... {len(dirty) - 8} more")
else:
print("git: unavailable")

active = payload["active_session"]
if isinstance(active, dict):
if active.get("valid"):
print(f"active_session: {active.get('path')}")
if active.get("title"):
print(f"active_session_title: {_short(str(active['title']))}")
else:
print(f"active_session: invalid ({active.get('path')})")
else:
print("active_session: none")

latest_session = payload["latest_session"]
if isinstance(latest_session, dict):
print(f"latest_session: {latest_session.get('path')}")
if latest_session.get("title"):
print(f"latest_session_title: {_short(str(latest_session['title']))}")
if latest_session.get("note"):
print(f"latest_session_note: {_short(str(latest_session['note']))}")
if latest_session.get("handoff"):
print(f"latest_session_handoff: {latest_session['handoff']}")
else:
print(f"latest_session: none ({_work_root(target)})")

dogfood = payload["dogfood"]
print(f"dogfood_ready: {dogfood.get('ready')}")
if dogfood.get("error"):
print(f"dogfood_error: {dogfood['error']}")
latest_run = dogfood.get("latest_run")
if isinstance(latest_run, dict):
print(
"latest_run: "
f"{latest_run.get('started_at', '')} "
f"[{latest_run.get('status', 'unknown')}] {latest_run.get('path')}"
)
if latest_run.get("task"):
print(f"latest_task: {_short(str(latest_run['task']))}")
else:
print("latest_run: none")

print(f"next_source: {payload['next_source']}")
print(f"next: {_short(str(payload['next']))}")
print(f"suggested_command: {payload['suggested_command']}")

recent = payload["recent_sessions"]
if isinstance(recent, list) and recent:
print("recent_sessions:")
for item in recent:
if not isinstance(item, dict):
continue
title = item.get("title") or item.get("id")
print(f" - {item.get('started_at')} [{item.get('status')}] {_short(str(title))}")
if payload.get("skipped_sessions"):
print(f"skipped_sessions: {payload['skipped_sessions']}", file=sys.stderr)
return 0


def next(*, target: Path, json_output: bool = False) -> int:
target = target.expanduser().resolve()
if not target.is_dir():
Expand Down
Loading
Loading