diff --git a/CHANGELOG.md b/CHANGELOG.md index c9173dd4..fc747a99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `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 run --queue-next` to queue the successful run's extracted next step, with duplicate pending task protection. - `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. @@ -57,6 +58,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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. +- `brigade work task add --from-next` now reuses an equivalent pending task instead of adding duplicates. - 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 e710132f..b4de0a0c 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,7 @@ brigade work task add "build the next slice" brigade work task add --from-next brigade work task done brigade work run +brigade work run --queue-next brigade work run "review today's changes" brigade work start "next slice" brigade work note "wired parser and tests" @@ -177,7 +178,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, 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`. +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. Add `--queue-next` to queue the successful run's extracted next step for the next session; duplicate pending task text is reused instead of added again. 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 91762e75..cb85678b 100644 --- a/src/brigade/cli.py +++ b/src/brigade/cli.py @@ -188,6 +188,7 @@ def _build_parser() -> argparse.ArgumentParser: ) p_work_run.add_argument("--timeout-seconds", type=float, default=DEFAULT_TIMEOUT_SECONDS, help="Per-agent timeout.") p_work_run.add_argument("--recap-limit", type=int, default=1, help="Maximum sessions to include in the final recap.") + p_work_run.add_argument("--queue-next", action="store_true", help="Queue the extracted next step after a successful run.") p_work_start = work_sub.add_parser("start", help="Start a local Brigade work session.") p_work_start.add_argument("title", nargs="*", help="Optional session title.") p_work_start.add_argument("--target", "-t", type=Path, default=Path("."), help="Repo or workspace for the session.") @@ -531,6 +532,7 @@ def main(argv=None) -> int: native_read_only_sandbox=args.native_read_only_sandbox, timeout_seconds=args.timeout_seconds, recap_limit=args.recap_limit, + queue_next=args.queue_next, ) if args.work_command == "start": title = " ".join(args.title) if args.title else None diff --git a/src/brigade/work_cmd.py b/src/brigade/work_cmd.py index 0adb2728..1de7ded6 100644 --- a/src/brigade/work_cmd.py +++ b/src/brigade/work_cmd.py @@ -158,6 +158,10 @@ def _task_sort_key(task: dict[str, Any]) -> str: return str(task.get("created_at") or task.get("id") or "") +def _task_text_key(text: str) -> str: + return " ".join(text.casefold().split()) + + def _pending_tasks(target: Path) -> list[dict[str, Any]]: ledger = _read_task_ledger(target) tasks = [ @@ -172,6 +176,16 @@ def _pending_tasks(target: Path) -> list[dict[str, Any]]: return tasks +def _find_pending_task_by_text(target: Path, text: str) -> dict[str, Any] | None: + wanted = _task_text_key(text) + if not wanted: + return None + for task in _pending_tasks(target): + if _task_text_key(str(task.get("text") or "")) == wanted: + return task + return None + + 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]] = [] @@ -187,10 +201,10 @@ def _find_task(target: Path, task_id: str) -> tuple[dict[str, Any] | None, dict[ return None, ledger -def _make_task(text: str, *, source: str = "manual") -> dict[str, Any]: +def _make_task(text: str, *, source: str = "manual", metadata: dict[str, Any] | None = None) -> dict[str, Any]: now = _now() created = now.isoformat() - return { + task = { "id": f"{now.strftime('%Y%m%d-%H%M%S')}-{_slug(text)}-{uuid4().hex[:6]}", "text": text, "status": "pending", @@ -198,14 +212,67 @@ def _make_task(text: str, *, source: str = "manual") -> dict[str, Any]: "created_at": created, "updated_at": created, } + if metadata: + task["metadata"] = metadata + return task -def _add_task(target: Path, text: str, *, source: str = "manual") -> dict[str, Any]: +def _add_task( + target: Path, + text: str, + *, + source: str = "manual", + metadata: dict[str, Any] | None = None, +) -> tuple[dict[str, Any], bool]: ledger = _read_task_ledger(target) - task = _make_task(text, source=source) + existing = _find_pending_task_by_text(target, text) + if existing is not None: + return existing, False + task = _make_task(text, source=source, metadata=metadata) ledger["tasks"].append(task) _write_task_ledger(target, ledger) - return task + return task, True + + +def _latest_run_next_metadata(target: Path) -> tuple[str | None, dict[str, Any]]: + dogfood = _dogfood_snapshot(target) + next_step = dogfood.get("next") if isinstance(dogfood.get("next"), str) else None + latest = dogfood.get("latest_run") if isinstance(dogfood.get("latest_run"), dict) else None + metadata: dict[str, Any] = { + "dogfood_next_source": dogfood.get("next_source"), + } + if isinstance(latest, dict): + metadata.update( + { + "run_path": latest.get("path"), + "run_started_at": latest.get("started_at"), + "run_status": latest.get("status"), + "run_task": latest.get("task"), + } + ) + return next_step.strip() if next_step and next_step.strip() else None, metadata + + +def _queue_latest_next( + target: Path, + *, + session_dir: Path | None = None, + session_title: str | None = None, +) -> tuple[dict[str, Any] | None, bool, str | None]: + next_step, metadata = _latest_run_next_metadata(target) + if not next_step: + return None, False, "no extracted next step is available" + if session_dir is not None: + metadata["session_path"] = str(session_dir) + if session_title: + metadata["session_title"] = session_title + task, created = _add_task( + target, + next_step, + source="latest_dogfood_run", + metadata=metadata, + ) + return task, created, None def _read_session(path: Path) -> dict[str, Any] | None: @@ -560,6 +627,16 @@ def _active_session_info(target: Path) -> dict[str, Any] | None: } +def _active_session_dir(target: Path) -> Path | None: + current = _current_path(target) + if not current.exists(): + return None + session_id = current.read_text().strip() + if not session_id: + return None + return _work_root(target) / session_id + + def _next_payload(target: Path) -> dict[str, Any]: target = target.expanduser().resolve() active = _active_session_info(target) @@ -1087,6 +1164,11 @@ def tasks(*, target: Path, all_tasks: bool = False, json_output: bool = False) - print(f"- {task.get('id')} [{status_text}] {_short(str(task.get('text', '')))}") if task.get("source"): print(f" source: {task['source']}") + metadata = task.get("metadata") if isinstance(task.get("metadata"), dict) else {} + if metadata.get("run_path"): + print(f" run: {metadata['run_path']}") + if metadata.get("session_path"): + print(f" session: {metadata['session_path']}") if task.get("completed_at"): print(f" completed_at: {task['completed_at']}") return 0 @@ -1103,19 +1185,21 @@ def task_add(*, target: Path, text: str | None = None, from_next: bool = False) 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(): + next_step, metadata = _latest_run_next_metadata(target) + if not next_step: print("error: no extracted next step is available", file=sys.stderr) return 1 - task_text = next_step.strip() + task_text = next_step source = "latest_dogfood_run" + else: + metadata = None if not task_text: print("error: task text is required", file=sys.stderr) return 2 - task = _add_task(target, task_text, source=source) + task, created = _add_task(target, task_text, source=source, metadata=metadata) print(f"task: {task['id']}") print(f"status: {task['status']}") + print(f"created: {created}") print(f"text: {task['text']}") return 0 @@ -1134,6 +1218,11 @@ def task_show(*, target: Path, task_id: str) -> int: print(f"source: {task.get('source', '')}") print(f"created_at: {task.get('created_at', '')}") print(f"updated_at: {task.get('updated_at', '')}") + metadata = task.get("metadata") if isinstance(task.get("metadata"), dict) else {} + if metadata: + print("metadata:") + for key in sorted(metadata): + print(f" {key}: {metadata[key]}") if task.get("completed_at"): print(f"completed_at: {task['completed_at']}") print(f"text: {task.get('text', '')}") @@ -1341,6 +1430,7 @@ def run( native_read_only_sandbox: bool = False, timeout_seconds: float = dogfood_cmd.DEFAULT_TIMEOUT_SECONDS, recap_limit: int = 1, + queue_next: bool = False, ) -> int: if recap_limit < 1: print("error: --recap-limit must be a positive integer", file=sys.stderr) @@ -1358,6 +1448,7 @@ def run( start_rc = start(target=target, title=session_title) if start_rc != 0: return start_rc + session_dir = _active_session_dir(target) dogfood_rc = 1 try: @@ -1386,6 +1477,16 @@ def run( task["completed_at"] = now task["completed_session_title"] = session_title _write_task_ledger(target, ledger) + if dogfood_rc == 0 and queue_next: + queued_task, created, reason = _queue_latest_next( + target, + session_dir=session_dir, + session_title=session_title, + ) + if queued_task is None: + print(f"queued_next: skipped ({reason})") + else: + print(f"queued_next: {queued_task.get('id')} ({'created' if created else 'existing'})") recap(target=target, limit=recap_limit) return dogfood_rc diff --git a/tests/test_work_cmd.py b/tests/test_work_cmd.py index 0c88a2c4..caa3417b 100644 --- a/tests/test_work_cmd.py +++ b/tests/test_work_cmd.py @@ -514,7 +514,7 @@ def test_work_task_ledger_add_list_show_and_done(tmp_path, monkeypatch, capsys): assert payload["tasks"][0]["completed_at"] == "2026-05-26T12:30:00+00:00" -def test_work_task_add_from_next(tmp_path, monkeypatch, capsys): +def test_work_task_add_from_next_deduplicates_pending_task(tmp_path, monkeypatch, capsys): _init_git_repo(tmp_path) dogfood_cmd.init(target=tmp_path) run_dir = tmp_path / ".brigade" / "runs" / "latest" @@ -530,6 +530,12 @@ def test_work_task_add_from_next(tmp_path, monkeypatch, capsys): assert work_cmd.task_add(target=tmp_path, from_next=True) == 0 out = capsys.readouterr().out assert "Build from extracted next." in out + assert "created: True" in out + first_id = out.split("task: ", 1)[1].splitlines()[0] + assert work_cmd.task_add(target=tmp_path, from_next=True) == 0 + out = capsys.readouterr().out + assert f"task: {first_id}" in out + assert "created: False" 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" @@ -813,6 +819,95 @@ def fake_dogfood_run(task, **kwargs): assert ledger["tasks"][0]["completed_session_title"] == "Build queued task" +def test_work_run_queue_next_adds_extracted_followup(tmp_path, monkeypatch, capsys): + _init_git_repo(tmp_path) + artifacts_dir = tmp_path / ".brigade" / "runs" + dogfood_cmd.init(target=tmp_path, artifacts_dir=artifacts_dir) + times = iter( + [ + 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)) + + def fake_dogfood_run(task, **kwargs): + 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 queued follow-up.\n") + return 0 + + monkeypatch.setattr(dogfood_cmd, "run", fake_dogfood_run) + + assert ( + work_cmd.run( + "review the repo", + target=tmp_path, + output_dir=artifacts_dir / "new", + handoff=False, + queue_next=True, + ) + == 0 + ) + out = capsys.readouterr().out + assert "queued_next:" in out + assert "(created)" in out + ledger = json.loads((tmp_path / ".brigade" / "work" / "tasks.json").read_text()) + assert ledger["tasks"][0]["status"] == "pending" + assert ledger["tasks"][0]["text"] == "Build queued follow-up." + assert ledger["tasks"][0]["source"] == "latest_dogfood_run" + assert ledger["tasks"][0]["metadata"]["run_path"] == str(artifacts_dir / "new") + assert ledger["tasks"][0]["metadata"]["session_title"] == "review the repo" + + +def test_work_run_queue_next_reuses_existing_pending_task(tmp_path, monkeypatch, capsys): + _init_git_repo(tmp_path) + artifacts_dir = tmp_path / ".brigade" / "runs" + dogfood_cmd.init(target=tmp_path, artifacts_dir=artifacts_dir) + 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), + ] + ) + monkeypatch.setattr(work_cmd, "_now", lambda: next(times)) + assert work_cmd.task_add(target=tmp_path, text="Build queued follow-up.") == 0 + capsys.readouterr() + + def fake_dogfood_run(task, **kwargs): + 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 queued follow-up.\n") + return 0 + + monkeypatch.setattr(dogfood_cmd, "run", fake_dogfood_run) + + assert ( + work_cmd.run( + "review the repo", + target=tmp_path, + output_dir=artifacts_dir / "new", + handoff=False, + queue_next=True, + ) + == 0 + ) + out = capsys.readouterr().out + assert "(existing)" in out + ledger = json.loads((tmp_path / ".brigade" / "work" / "tasks.json").read_text()) + assert len(ledger["tasks"]) == 1 + + def test_work_run_closes_session_when_dogfood_fails(tmp_path, monkeypatch): _init_git_repo(tmp_path) dogfood_cmd.init(target=tmp_path) @@ -1085,6 +1180,7 @@ def fake_run(task, **kwargs): "12", "--recap-limit", "2", + "--queue-next", ] ) == 0 @@ -1101,6 +1197,7 @@ def fake_run(task, **kwargs): "native_read_only_sandbox": True, "timeout_seconds": 12.0, "recap_limit": 2, + "queue_next": True, }