From 3548a45fe8a378951546220abbd19bbfe6f1b348 Mon Sep 17 00:00:00 2001 From: Solomon Neas Date: Tue, 26 May 2026 19:50:09 -0400 Subject: [PATCH] feat: add work task ledger --- CHANGELOG.md | 2 + README.md | 6 +- src/brigade/cli.py | 29 ++++++ src/brigade/work_cmd.py | 223 ++++++++++++++++++++++++++++++++++++++++ tests/test_work_cmd.py | 159 ++++++++++++++++++++++++++++ 5 files changed, 418 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4a88b0f..c9173dd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `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 tasks` plus `brigade work task add/show/done` to manage a gitignored local task ledger under `.brigade/work/tasks.json`. - `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. @@ -55,6 +56,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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. +- `brigade work run` now consumes the oldest pending ledger task before falling back to the latest extracted dogfood next step, and marks consumed tasks done after successful runs. - 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`. diff --git a/README.md b/README.md index 4ae23b17..e710132f 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,10 @@ brigade work brief brigade work brief --json brigade work next brigade work next --json +brigade work tasks +brigade work task add "build the next slice" +brigade work task add --from-next +brigade work task done brigade work run brigade work run "review today's changes" brigade work start "next slice" @@ -173,7 +177,7 @@ CLI runs write artifacts by default under `.brigade/runs/` below `--cwd`; do Use `--output-dir ` 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 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//`, 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, pending local tasks, latest dogfood run, extracted next task, recent sessions, and the command to continue; add `--json` for wrappers. Use `brigade work tasks` to list the local task ledger stored in `.brigade/work/tasks.json`, `brigade work task add "..."` to queue work manually, `brigade work task add --from-next` to promote the latest extracted dogfood next step, and `brigade work task done ` to close queued work. 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, consumes the oldest pending ledger task when no task is passed, otherwise uses the latest extracted dogfood next step, runs `brigade dogfood`, marks consumed ledger tasks done after successful runs, 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//`, 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. diff --git a/src/brigade/cli.py b/src/brigade/cli.py index bf0c592b..91762e75 100644 --- a/src/brigade/cli.py +++ b/src/brigade/cli.py @@ -139,6 +139,23 @@ def _build_parser() -> argparse.ArgumentParser: 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.") + p_work_tasks = work_sub.add_parser("tasks", help="List pending work tasks.") + p_work_tasks.add_argument("--target", "-t", type=Path, default=Path("."), help="Repo or workspace to inspect.") + p_work_tasks.add_argument("--all", action="store_true", help="Include completed tasks.") + p_work_tasks.add_argument("--json", action="store_true", help="Print machine-readable JSON.") + p_work_task = work_sub.add_parser("task", help="Add, show, or complete one work task.") + task_sub = p_work_task.add_subparsers(dest="task_command", metavar="") + task_sub.required = True + p_work_task_add = task_sub.add_parser("add", help="Add a pending work task.") + p_work_task_add.add_argument("text", nargs="*", help="Task text.") + p_work_task_add.add_argument("--target", "-t", type=Path, default=Path("."), help="Repo or workspace to update.") + p_work_task_add.add_argument("--from-next", action="store_true", help="Add the latest extracted dogfood next step.") + p_work_task_show = task_sub.add_parser("show", help="Show one work task.") + p_work_task_show.add_argument("task_id", help="Task id or unique prefix.") + p_work_task_show.add_argument("--target", "-t", type=Path, default=Path("."), help="Repo or workspace to inspect.") + p_work_task_done = task_sub.add_parser("done", help="Mark one work task done.") + p_work_task_done.add_argument("task_id", help="Task id or unique prefix.") + p_work_task_done.add_argument("--target", "-t", type=Path, default=Path("."), help="Repo or workspace to update.") p_work_list = work_sub.add_parser("list", help="List recent Brigade work sessions.") p_work_list.add_argument("--target", "-t", type=Path, default=Path("."), help="Repo or workspace to inspect.") p_work_list.add_argument("--limit", type=int, default=10, help="Maximum sessions to show.") @@ -480,6 +497,18 @@ def main(argv=None) -> int: 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 == "tasks": + return work_cmd.tasks(target=args.target, all_tasks=args.all, json_output=args.json) + if args.work_command == "task": + if args.task_command == "add": + text = " ".join(args.text) if args.text else None + return work_cmd.task_add(target=args.target, text=text, from_next=args.from_next) + if args.task_command == "show": + return work_cmd.task_show(target=args.target, task_id=args.task_id) + if args.task_command == "done": + return work_cmd.task_done(target=args.target, task_id=args.task_id) + parser.error(f"unknown task command: {args.task_command}") + return 2 if args.work_command == "list": return work_cmd.list_sessions(target=args.target, limit=args.limit) if args.work_command == "latest": diff --git a/src/brigade/work_cmd.py b/src/brigade/work_cmd.py index 11cbb8b4..0adb2728 100644 --- a/src/brigade/work_cmd.py +++ b/src/brigade/work_cmd.py @@ -63,6 +63,10 @@ def _current_path(target: Path) -> Path: return _work_root(target) / "current" +def _tasks_path(target: Path) -> Path: + return _work_root(target) / "tasks.json" + + def _git_snapshot(target: Path) -> dict[str, Any]: repo_root = _git_value(target, "rev-parse", "--show-toplevel") if repo_root is None: @@ -122,9 +126,88 @@ def _session_snapshot(target: Path) -> dict[str, Any]: def _write_json(path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n") +def _read_task_ledger(target: Path) -> dict[str, Any]: + path = _tasks_path(target) + if not path.exists(): + return {"version": 1, "tasks": []} + try: + payload = json.loads(path.read_text()) + except (OSError, json.JSONDecodeError): + return {"version": 1, "tasks": []} + if not isinstance(payload, dict): + return {"version": 1, "tasks": []} + tasks = payload.get("tasks") + if not isinstance(tasks, list): + payload["tasks"] = [] + payload["version"] = 1 + return payload + + +def _write_task_ledger(target: Path, payload: dict[str, Any]) -> None: + payload["version"] = 1 + if not isinstance(payload.get("tasks"), list): + payload["tasks"] = [] + _write_json(_tasks_path(target), payload) + + +def _task_sort_key(task: dict[str, Any]) -> str: + return str(task.get("created_at") or task.get("id") or "") + + +def _pending_tasks(target: Path) -> list[dict[str, Any]]: + ledger = _read_task_ledger(target) + tasks = [ + task + for task in ledger["tasks"] + if isinstance(task, dict) + and task.get("status", "pending") == "pending" + and isinstance(task.get("text"), str) + and task["text"].strip() + ] + tasks.sort(key=_task_sort_key) + return tasks + + +def _find_task(target: Path, task_id: str) -> tuple[dict[str, Any] | None, dict[str, Any]]: + ledger = _read_task_ledger(target) + matches: list[dict[str, Any]] = [] + for task in ledger["tasks"]: + if not isinstance(task, dict): + continue + if task.get("id") == task_id: + return task, ledger + if isinstance(task.get("id"), str) and task["id"].startswith(task_id): + matches.append(task) + if len(matches) == 1: + return matches[0], ledger + return None, ledger + + +def _make_task(text: str, *, source: str = "manual") -> dict[str, Any]: + now = _now() + created = now.isoformat() + return { + "id": f"{now.strftime('%Y%m%d-%H%M%S')}-{_slug(text)}-{uuid4().hex[:6]}", + "text": text, + "status": "pending", + "source": source, + "created_at": created, + "updated_at": created, + } + + +def _add_task(target: Path, text: str, *, source: str = "manual") -> dict[str, Any]: + ledger = _read_task_ledger(target) + task = _make_task(text, source=source) + ledger["tasks"].append(task) + _write_task_ledger(target, ledger) + return task + + def _read_session(path: Path) -> dict[str, Any] | None: try: payload = json.loads((path / "session.json").read_text()) @@ -240,17 +323,29 @@ def _session_info(path: Path, payload: dict[str, Any]) -> dict[str, Any]: def _resolve_next_task(target: Path) -> dict[str, Any]: + pending = _pending_tasks(target) + if pending: + task = pending[0] + return { + "task": str(task.get("text", "")).strip(), + "source": "task_ledger", + "task_id": task.get("id"), + "ledger_task": task, + "dogfood": _dogfood_snapshot(target), + } dogfood = _dogfood_snapshot(target) next_step = dogfood.get("next") if isinstance(dogfood.get("next"), str) else None if next_step and next_step.strip(): return { "task": next_step.strip(), "source": "latest_dogfood_run", + "task_id": None, "dogfood": dogfood, } return { "task": dogfood_cmd.DEFAULT_TASK, "source": "default_review", + "task_id": None, "dogfood": dogfood, } @@ -476,6 +571,7 @@ def _next_payload(target: Path) -> dict[str, Any]: "active_session": active, "dogfood": dogfood, "next_source": resolved["source"], + "task_id": resolved.get("task_id"), "next": str(resolved["task"]), "suggested_command": suggested, } @@ -484,6 +580,8 @@ 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 source == "task_ledger": + return "brigade work run" 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" @@ -498,6 +596,7 @@ def _brief_payload(target: Path, *, limit: int = 3) -> dict[str, Any]: resolved = _resolve_next_task(target) git = _git_snapshot(target) suggested = _suggested_command(active, resolved["task"], resolved["source"]) + pending = _pending_tasks(target) return { "target": str(target), "git": git, @@ -505,8 +604,11 @@ def _brief_payload(target: Path, *, limit: int = 3) -> dict[str, Any]: "latest_session": latest_session, "recent_sessions": recent_sessions, "skipped_sessions": skipped, + "tasks_path": str(_tasks_path(target)), + "pending_tasks": pending, "dogfood": resolved["dogfood"], "next_source": resolved["source"], + "task_id": resolved.get("task_id"), "next": str(resolved["task"]), "suggested_command": suggested, } @@ -932,9 +1034,21 @@ def brief(*, target: Path, limit: int = 3, json_output: bool = False) -> int: print("latest_run: none") print(f"next_source: {payload['next_source']}") + if payload.get("task_id"): + print(f"task_id: {payload['task_id']}") print(f"next: {_short(str(payload['next']))}") print(f"suggested_command: {payload['suggested_command']}") + pending = payload["pending_tasks"] + if isinstance(pending, list) and pending: + print("pending_tasks:") + for task in pending[:5]: + if not isinstance(task, dict): + continue + print(f" - {task.get('id')} {_short(str(task.get('text', '')))}") + if len(pending) > 5: + print(f" ... {len(pending) - 5} more") + recent = payload["recent_sessions"] if isinstance(recent, list) and recent: print("recent_sessions:") @@ -948,6 +1062,103 @@ def brief(*, target: Path, limit: int = 3, json_output: bool = False) -> int: return 0 +def tasks(*, target: Path, all_tasks: bool = False, json_output: bool = False) -> int: + target = target.expanduser().resolve() + if not target.is_dir(): + print(f"error: --target is not a directory: {target}", file=sys.stderr) + return 2 + ledger = _read_task_ledger(target) + task_items = [task for task in ledger["tasks"] if isinstance(task, dict)] + task_items.sort(key=_task_sort_key) + if not all_tasks: + task_items = [task for task in task_items if task.get("status", "pending") == "pending"] + + if json_output: + print(json.dumps({"tasks_path": str(_tasks_path(target)), "tasks": task_items}, indent=2, sort_keys=True)) + return 0 + + print(f"work tasks: {target}") + print(f"tasks_path: {_tasks_path(target)}") + if not task_items: + print("tasks: none") + return 0 + for task in task_items: + status_text = task.get("status", "pending") + print(f"- {task.get('id')} [{status_text}] {_short(str(task.get('text', '')))}") + if task.get("source"): + print(f" source: {task['source']}") + if task.get("completed_at"): + print(f" completed_at: {task['completed_at']}") + return 0 + + +def task_add(*, target: Path, text: str | None = None, from_next: bool = False) -> int: + target = target.expanduser().resolve() + if not target.is_dir(): + print(f"error: --target is not a directory: {target}", file=sys.stderr) + return 2 + if from_next and text: + print("error: pass task text or --from-next, not both", file=sys.stderr) + return 2 + task_text = (text or "").strip() + source = "manual" + if from_next: + dogfood = _dogfood_snapshot(target) + next_step = dogfood.get("next") if isinstance(dogfood.get("next"), str) else None + if not next_step or not next_step.strip(): + print("error: no extracted next step is available", file=sys.stderr) + return 1 + task_text = next_step.strip() + source = "latest_dogfood_run" + if not task_text: + print("error: task text is required", file=sys.stderr) + return 2 + task = _add_task(target, task_text, source=source) + print(f"task: {task['id']}") + print(f"status: {task['status']}") + print(f"text: {task['text']}") + return 0 + + +def task_show(*, target: Path, task_id: str) -> int: + target = target.expanduser().resolve() + if not target.is_dir(): + print(f"error: --target is not a directory: {target}", file=sys.stderr) + return 2 + task, _ = _find_task(target, task_id) + if task is None: + print(f"error: task not found: {task_id}", file=sys.stderr) + return 1 + print(f"task: {task.get('id')}") + print(f"status: {task.get('status', 'pending')}") + print(f"source: {task.get('source', '')}") + print(f"created_at: {task.get('created_at', '')}") + print(f"updated_at: {task.get('updated_at', '')}") + if task.get("completed_at"): + print(f"completed_at: {task['completed_at']}") + print(f"text: {task.get('text', '')}") + return 0 + + +def task_done(*, target: Path, task_id: str) -> int: + target = target.expanduser().resolve() + if not target.is_dir(): + print(f"error: --target is not a directory: {target}", file=sys.stderr) + return 2 + task, ledger = _find_task(target, task_id) + if task is None: + print(f"error: task not found: {task_id}", file=sys.stderr) + return 1 + now = _now().isoformat() + task["status"] = "done" + task["updated_at"] = now + task["completed_at"] = now + _write_task_ledger(target, ledger) + print(f"task: {task.get('id')}") + print("status: done") + return 0 + + def next(*, target: Path, json_output: bool = False) -> int: target = target.expanduser().resolve() if not target.is_dir(): @@ -990,6 +1201,8 @@ def next(*, target: Path, json_output: bool = False) -> int: task = str(payload["next"]) print(f"next_source: {payload['next_source']}") + if payload.get("task_id"): + print(f"task_id: {payload['task_id']}") print(f"next: {_short(task)}") print(f"suggested_command: {payload['suggested_command']}") return 0 @@ -1140,6 +1353,7 @@ def run( resolved = _resolve_next_task(target) task_text = task or str(resolved["task"]) + consumed_task_id = resolved.get("task_id") if task is None and resolved.get("source") == "task_ledger" else None session_title = title or task_text start_rc = start(target=target, title=session_title) if start_rc != 0: @@ -1163,6 +1377,15 @@ def run( if end_rc != 0: return end_rc if dogfood_rc == 0 else dogfood_rc + if dogfood_rc == 0 and isinstance(consumed_task_id, str): + task, ledger = _find_task(target, consumed_task_id) + if task is not None: + now = _now().isoformat() + task["status"] = "done" + task["updated_at"] = now + task["completed_at"] = now + task["completed_session_title"] = session_title + _write_task_ledger(target, ledger) recap(target=target, limit=recap_limit) return dogfood_rc diff --git a/tests/test_work_cmd.py b/tests/test_work_cmd.py index 6b40df7d..0c88a2c4 100644 --- a/tests/test_work_cmd.py +++ b/tests/test_work_cmd.py @@ -477,6 +477,84 @@ def test_work_brief_json_reports_recent_sessions(tmp_path, monkeypatch, capsys): assert payload["suggested_command"] == 'brigade work end --note "..." --handoff' +def test_work_task_ledger_add_list_show_and_done(tmp_path, monkeypatch, capsys): + _init_git_repo(tmp_path) + times = iter( + [ + datetime(2026, 5, 26, 12, 0, 0, tzinfo=timezone.utc), + datetime(2026, 5, 26, 12, 30, 0, tzinfo=timezone.utc), + ] + ) + monkeypatch.setattr(work_cmd, "_now", lambda: next(times)) + + assert work_cmd.task_add(target=tmp_path, text="Build task ledger") == 0 + out = capsys.readouterr().out + assert "task:" in out + task_id = out.split("task: ", 1)[1].splitlines()[0] + + assert work_cmd.tasks(target=tmp_path) == 0 + out = capsys.readouterr().out + assert "work tasks:" in out + assert task_id in out + assert "[pending] Build task ledger" in out + + assert work_cmd.task_show(target=tmp_path, task_id=task_id[:12]) == 0 + out = capsys.readouterr().out + assert f"task: {task_id}" in out + assert "status: pending" in out + assert "text: Build task ledger" in out + + assert work_cmd.task_done(target=tmp_path, task_id=task_id[:12]) == 0 + assert "status: done" in capsys.readouterr().out + assert work_cmd.tasks(target=tmp_path) == 0 + assert "tasks: none" in capsys.readouterr().out + assert work_cmd.tasks(target=tmp_path, all_tasks=True, json_output=True) == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["tasks"][0]["status"] == "done" + assert payload["tasks"][0]["completed_at"] == "2026-05-26T12:30:00+00:00" + + +def test_work_task_add_from_next(tmp_path, monkeypatch, capsys): + _init_git_repo(tmp_path) + dogfood_cmd.init(target=tmp_path) + run_dir = tmp_path / ".brigade" / "runs" / "latest" + run_dir.mkdir(parents=True) + _write_json(run_dir / "run.json", {"started_at": "2026-05-26T12:10:00Z", "status": "ok", "task": "review"}) + (run_dir / "final.txt").write_text("Done.\n\nNext step: Build from extracted next.\n") + monkeypatch.setattr( + work_cmd, + "_now", + lambda: datetime(2026, 5, 26, 12, 0, 0, tzinfo=timezone.utc), + ) + + assert work_cmd.task_add(target=tmp_path, from_next=True) == 0 + out = capsys.readouterr().out + assert "Build from extracted next." in out + assert work_cmd.next(target=tmp_path, json_output=True) == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["next_source"] == "task_ledger" + assert payload["next"] == "Build from extracted next." + assert payload["task_id"] + + +def test_work_brief_includes_pending_tasks(tmp_path, monkeypatch, capsys): + _init_git_repo(tmp_path) + monkeypatch.setattr( + work_cmd, + "_now", + lambda: datetime(2026, 5, 26, 12, 0, 0, tzinfo=timezone.utc), + ) + assert work_cmd.task_add(target=tmp_path, text="Build queued task") == 0 + capsys.readouterr() + + assert work_cmd.brief(target=tmp_path, json_output=True) == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["next_source"] == "task_ledger" + assert payload["next"] == "Build queued task" + assert payload["pending_tasks"][0]["text"] == "Build queued task" + assert payload["suggested_command"] == "brigade work run" + + def test_work_next_reports_latest_next_as_default_task(tmp_path, monkeypatch, capsys): _init_git_repo(tmp_path) dogfood_cmd.init(target=tmp_path) @@ -692,6 +770,49 @@ def fake_dogfood_run(task, **kwargs): assert payload["title"] == "Build consumed task." +def test_work_run_consumes_pending_task_before_latest_next(tmp_path, monkeypatch): + _init_git_repo(tmp_path) + artifacts_dir = tmp_path / ".brigade" / "runs" + dogfood_cmd.init(target=tmp_path, artifacts_dir=artifacts_dir) + latest_dir = artifacts_dir / "latest" + latest_dir.mkdir(parents=True) + _write_json( + latest_dir / "run.json", + {"started_at": "2026-05-26T11:00:00Z", "status": "ok", "task": "review"}, + ) + (latest_dir / "final.txt").write_text("Done.\n\nNext step: Build extracted task.\n") + times = iter( + [ + datetime(2026, 5, 26, 11, 30, 0, tzinfo=timezone.utc), + datetime(2026, 5, 26, 12, 0, 0, tzinfo=timezone.utc), + datetime(2026, 5, 26, 13, 0, 0, tzinfo=timezone.utc), + datetime(2026, 5, 26, 13, 0, 1, tzinfo=timezone.utc), + ] + ) + monkeypatch.setattr(work_cmd, "_now", lambda: next(times)) + assert work_cmd.task_add(target=tmp_path, text="Build queued task") == 0 + seen = {} + + def fake_dogfood_run(task, **kwargs): + seen["task"] = task + run_dir = kwargs["output_dir"] + run_dir.mkdir(parents=True) + _write_json( + run_dir / "run.json", + {"started_at": "2026-05-26T12:10:00Z", "status": "ok", "task": task}, + ) + (run_dir / "final.txt").write_text("Done.\n\nNext step: Build follow-up.\n") + return 0 + + monkeypatch.setattr(dogfood_cmd, "run", fake_dogfood_run) + + assert work_cmd.run(None, target=tmp_path, output_dir=artifacts_dir / "new", handoff=False) == 0 + assert seen["task"] == "Build queued task" + ledger = json.loads((tmp_path / ".brigade" / "work" / "tasks.json").read_text()) + assert ledger["tasks"][0]["status"] == "done" + assert ledger["tasks"][0]["completed_session_title"] == "Build queued task" + + def test_work_run_closes_session_when_dogfood_fails(tmp_path, monkeypatch): _init_git_repo(tmp_path) dogfood_cmd.init(target=tmp_path) @@ -778,6 +899,44 @@ def fake_next(**kwargs): assert seen == {"target": tmp_path, "json_output": True} +def test_work_tasks_cli(tmp_path, monkeypatch): + seen = [] + + def fake_tasks(**kwargs): + seen.append(("tasks", kwargs)) + return 0 + + def fake_task_add(**kwargs): + seen.append(("add", kwargs)) + return 0 + + def fake_task_show(**kwargs): + seen.append(("show", kwargs)) + return 0 + + def fake_task_done(**kwargs): + seen.append(("done", kwargs)) + return 0 + + monkeypatch.setattr(work_cmd, "tasks", fake_tasks) + monkeypatch.setattr(work_cmd, "task_add", fake_task_add) + monkeypatch.setattr(work_cmd, "task_show", fake_task_show) + monkeypatch.setattr(work_cmd, "task_done", fake_task_done) + + assert cli.main(["work", "tasks", "--target", str(tmp_path), "--all", "--json"]) == 0 + assert cli.main(["work", "task", "add", "build", "queue", "--target", str(tmp_path)]) == 0 + assert cli.main(["work", "task", "add", "--target", str(tmp_path), "--from-next"]) == 0 + assert cli.main(["work", "task", "show", "abc123", "--target", str(tmp_path)]) == 0 + assert cli.main(["work", "task", "done", "abc123", "--target", str(tmp_path)]) == 0 + assert seen == [ + ("tasks", {"target": tmp_path, "all_tasks": True, "json_output": True}), + ("add", {"target": tmp_path, "text": "build queue", "from_next": False}), + ("add", {"target": tmp_path, "text": None, "from_next": True}), + ("show", {"target": tmp_path, "task_id": "abc123"}), + ("done", {"target": tmp_path, "task_id": "abc123"}), + ] + + def test_work_bootstrap_cli(tmp_path, monkeypatch): seen = {}