diff --git a/.gitignore b/.gitignore index 6c8fc501..17539c04 100644 --- a/.gitignore +++ b/.gitignore @@ -31,7 +31,7 @@ htmlcov/ .DS_Store Thumbs.db -# claude / agent local state (side-harness only; solo-mise block below +# claude / agent local state (side-harness only; brigade block below # handles the handoff path with TEMPLATE.md kept tracked) .codex/ .openclaw/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 70eb856c..b0f81143 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,8 +38,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `brigade work recap` to summarize recent or date-filtered work sessions. - `brigade work run` to start a work session, run dogfood, close the session, write a work handoff, and print a recap in one command. - `brigade work resume` to show the active or latest work session, latest dogfood run, extracted next step, and suggested command. +- `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 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. - Roster-level and per-agent `timeout_seconds` controls for bounded CLI calls. - `brigade run --read-only` prompt policy for planning and review runs that should inspect and recommend only, with native `codex exec --sandbox read-only` enforcement for Codex agents. diff --git a/README.md b/README.md index 568a4fcf..d7c842aa 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,7 @@ brigade dogfood --target /path/to/repo brigade work status brigade work doctor brigade work resume +brigade work next brigade work run brigade work run "review today's changes" brigade work start "next slice" @@ -166,9 +167,9 @@ 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 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. `brigade work run` is the one-command daily loop: it starts a work session, 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 default next-slice review, `--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 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. `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`. -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`. 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. +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. Inspect local work sessions with `brigade work list`, `brigade work latest`, or `brigade work show `. Use `brigade work recap` for a compact summary of recent sessions, or add `--since YYYY-MM-DD` for a day-range recap. diff --git a/src/brigade/cli.py b/src/brigade/cli.py index bb6e6931..d8d3a515 100644 --- a/src/brigade/cli.py +++ b/src/brigade/cli.py @@ -118,6 +118,8 @@ def _build_parser() -> argparse.ArgumentParser: p_work_doctor.add_argument("--target", "-t", type=Path, default=Path("."), help="Repo or workspace to inspect.") 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_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_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.") @@ -443,6 +445,8 @@ def main(argv=None) -> int: return work_cmd.doctor(target=args.target) if args.work_command == "resume": return work_cmd.resume(target=args.target) + if args.work_command == "next": + return work_cmd.next(target=args.target) 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/install.py b/src/brigade/install.py index 6e9d8d82..bb954484 100644 --- a/src/brigade/install.py +++ b/src/brigade/install.py @@ -26,6 +26,8 @@ GITIGNORE_BEGIN = "# >>> brigade gitignore block >>>" GITIGNORE_END = "# <<< brigade gitignore block <<<" +LEGACY_GITIGNORE_BEGIN = "# >>> solo-mise gitignore block >>>" +LEGACY_GITIGNORE_END = "# <<< solo-mise gitignore block <<<" # Writer harness -> inbox-dir prefix. Only writer harnesses have an inbox. _WRITER_INBOX = { @@ -78,9 +80,15 @@ def apply_gitignore(target: Path, selection: Selection) -> str: gi.write_text(block) return "created" existing = gi.read_text() - if GITIGNORE_BEGIN in existing and GITIGNORE_END in existing: - prefix, _, rest = existing.partition(GITIGNORE_BEGIN) - _, _, suffix = rest.partition(GITIGNORE_END) + markers = ( + (GITIGNORE_BEGIN, GITIGNORE_END), + (LEGACY_GITIGNORE_BEGIN, LEGACY_GITIGNORE_END), + ) + for begin, end in markers: + if begin not in existing or end not in existing: + continue + prefix, _, rest = existing.partition(begin) + _, _, suffix = rest.partition(end) # Strip a trailing newline from prefix and a leading newline from suffix to avoid drift. new_text = prefix.rstrip("\n") + ("\n\n" if prefix.strip() else "") + block + suffix.lstrip("\n") gi.write_text(new_text) diff --git a/src/brigade/templates/depth/workspace.json b/src/brigade/templates/depth/workspace.json index f350459e..6a642b44 100644 --- a/src/brigade/templates/depth/workspace.json +++ b/src/brigade/templates/depth/workspace.json @@ -20,6 +20,7 @@ {"src": "memory/cards/pipeline-standups.md", "dst": "memory/cards/pipeline-standups.md"}, {"src": "memory/cards/obsidian-notes.md", "dst": "memory/cards/obsidian-notes.md"}, {"src": "memory/cards/backup-restic.md", "dst": "memory/cards/backup-restic.md"}, + {"src": "memory/memory-care.example.json", "dst": ".brigade/memory-care.example.json"}, {"src": "skills/note/SKILL.md", "dst": "skills/note/SKILL.md"}, {"src": "scripts/backup-restic.sh", "dst": "scripts/backup-restic.sh", "mode": "0755"} ], diff --git a/src/brigade/templates/memory/cards/memory-care-staleness.md b/src/brigade/templates/memory/cards/memory-care-staleness.md index 25f80f52..cdd4e3c6 100644 --- a/src/brigade/templates/memory/cards/memory-care-staleness.md +++ b/src/brigade/templates/memory/cards/memory-care-staleness.md @@ -33,6 +33,39 @@ Store scan state under `memory/cards/decay/`: The scanner should report at least total cards, fresh count, aging count, stale count, critical count, and refresh queue size. +`brigade init --depth workspace` also installs `.brigade/memory-care.example.json` as a local wiring contract for whatever scanner you use. Adapt that file for your scheduler or memory owner, but keep the output paths stable unless you also update the doctor integration. + +Minimal `scan-latest.json` shape: + +```json +{ + "scan_date": "2026-05-26", + "counts": { + "total": 12, + "fresh": 9, + "aging": 2, + "stale": 1, + "critical": 0 + }, + "refresh_queue_size": 1 +} +``` + +Minimal `refresh-queue.json` shape: + +```json +{ + "cards": [ + { + "file": "memory/cards/example.md", + "reason": "source-of-truth changed" + } + ] +} +``` + +`brigade doctor` treats missing decay output as advisory, stale scans older than 7 days as a warning, and corrupt scan or queue JSON as a failure. + ## Safe Refresh Rules Only auto-refresh cards when current facts are grounded in local source-of-truth files read during the run. Good sources include `TOOLS.md`, `MEMORY.md`, recent `memory/YYYY-MM-DD.md`, local project docs, local scripts, and repo health reports. diff --git a/src/brigade/templates/memory/memory-care.example.json b/src/brigade/templates/memory/memory-care.example.json new file mode 100644 index 00000000..bc214acc --- /dev/null +++ b/src/brigade/templates/memory/memory-care.example.json @@ -0,0 +1,11 @@ +{ + "_brigade_version": "0.1.0", + "_description": "Example config for a local memory-care scanner. Brigade does not run this scanner for you; it validates the output with `brigade doctor`.", + "cards_glob": "memory/cards/*.md", + "decay_dir": "memory/cards/decay", + "scan_output": "memory/cards/decay/scan-latest.json", + "refresh_queue": "memory/cards/decay/refresh-queue.json", + "stale_after_days": 7, + "schedule": "quiet-hours", + "doctor_command": "brigade doctor --target ." +} diff --git a/src/brigade/work_cmd.py b/src/brigade/work_cmd.py index 3e31c362..cab68d05 100644 --- a/src/brigade/work_cmd.py +++ b/src/brigade/work_cmd.py @@ -211,6 +211,22 @@ def _next_step(snapshot: dict[str, Any]) -> str | None: return None +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 + if next_step and next_step.strip(): + return { + "task": next_step.strip(), + "source": "latest_dogfood_run", + "dogfood": dogfood, + } + return { + "task": dogfood_cmd.DEFAULT_TASK, + "source": "default_review", + "dogfood": dogfood, + } + + def _display_session(path: Path, payload: dict[str, Any]) -> None: print(f"session: {path}") print(f"id: {payload.get('id', path.name)}") @@ -732,6 +748,58 @@ def resume(*, target: Path) -> int: return 0 +def next(*, target: Path) -> int: + target = target.expanduser().resolve() + if not target.is_dir(): + print(f"error: --target is not a directory: {target}", file=sys.stderr) + return 2 + + print(f"work next: {target}") + root = _work_root(target) + current = _current_path(target) + active_payload: dict[str, Any] | None = None + if current.exists(): + active_dir = root / current.read_text().strip() + active_payload = _read_session(active_dir) + if active_payload is None: + print(f"active_session: invalid ({active_dir})") + else: + print(f"active_session: {active_dir}") + print(f"active_session_status: {active_payload.get('status', 'unknown')}") + if active_payload.get("title"): + print(f"active_session_title: {_short(str(active_payload['title']))}") + else: + print("active_session: none") + + resolved = _resolve_next_task(target) + dogfood = resolved["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") + + task = str(resolved["task"]) + print(f"next_source: {resolved['source']}") + print(f"next: {_short(task)}") + if active_payload is not None: + print('suggested_command: brigade work end --note "..." --handoff') + elif resolved["source"] == "latest_dogfood_run": + print("suggested_command: brigade work run") + else: + print("suggested_command: brigade work run") + return 0 + + def run( task: str | None, *, @@ -755,7 +823,8 @@ def run( print(f"error: --target is not a directory: {target}", file=sys.stderr) return 2 - task_text = task or dogfood_cmd.DEFAULT_TASK + resolved = _resolve_next_task(target) + task_text = task or str(resolved["task"]) session_title = title or task_text start_rc = start(target=target, title=session_title) if start_rc != 0: diff --git a/tests/test_gitignore.py b/tests/test_gitignore.py index 2c2738d1..f1fefeaf 100644 --- a/tests/test_gitignore.py +++ b/tests/test_gitignore.py @@ -63,6 +63,39 @@ def test_init_idempotent_replaces_block(tmp_target: Path): assert second.count(install_mod.GITIGNORE_BEGIN) == 1 +def test_init_replaces_legacy_solo_mise_gitignore_block(tmp_target: Path): + tmp_target.mkdir() + (tmp_target / ".gitignore").write_text( + "\n".join( + [ + "# user rules", + "*.log", + "", + install_mod.LEGACY_GITIGNORE_BEGIN, + "GARBAGE_LINE", + install_mod.LEGACY_GITIGNORE_END, + "", + "# after block", + ".local-cache/", + "", + ] + ) + ) + + rc = install_selection(tmp_target, _repo_selection()) + + assert rc == 0 + gi = _read_gi(tmp_target) + assert "# user rules" in gi + assert ".local-cache/" in gi + assert install_mod.GITIGNORE_BEGIN in gi + assert install_mod.GITIGNORE_END in gi + assert install_mod.LEGACY_GITIGNORE_BEGIN not in gi + assert install_mod.LEGACY_GITIGNORE_END not in gi + assert "GARBAGE_LINE" not in gi + assert gi.count(install_mod.GITIGNORE_BEGIN) == 1 + + def test_init_preserves_user_edits_outside_block(tmp_target: Path): tmp_target.mkdir() pre = "node_modules/\n# user rules\n*.swp\n" diff --git a/tests/test_init.py b/tests/test_init.py index 408f40dd..e0c05586 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -92,6 +92,7 @@ def test_workspace_install_includes_memory_cards(tmp_target: Path): assert (tmp_target / "memory" / "cards" / card).is_file(), f"missing card {card}" assert (tmp_target / "memory" / "handoff-inbox").is_dir() assert (tmp_target / ".claude" / "memory-handoffs" / "processed").is_dir() + assert (tmp_target / ".brigade" / "memory-care.example.json").is_file() # skill + script land at the right paths, executable bit on the script assert (tmp_target / "skills" / "note" / "SKILL.md").is_file() backup = tmp_target / "scripts" / "backup-restic.sh" diff --git a/tests/test_work_cmd.py b/tests/test_work_cmd.py index 87be98a2..e94f94c6 100644 --- a/tests/test_work_cmd.py +++ b/tests/test_work_cmd.py @@ -418,6 +418,37 @@ def test_work_resume_suggests_work_run_from_latest_next(tmp_path, monkeypatch, c assert "suggested_command: brigade work run 'Build resume.'" in out +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) + 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 next command.\n") + monkeypatch.setattr(work_cmd.shutil, "which", lambda name: f"/usr/bin/{name}") + + assert work_cmd.next(target=tmp_path) == 0 + out = capsys.readouterr().out + assert "work next:" in out + assert "active_session: none" in out + assert "dogfood_ready: True" in out + assert "latest_run: 2026-05-26T12:10:00Z [ok]" in out + assert "next_source: latest_dogfood_run" in out + assert "next: Build next command." in out + assert "suggested_command: brigade work run" in out + + +def test_work_next_falls_back_to_default_review(tmp_path, capsys): + _init_git_repo(tmp_path) + + assert work_cmd.next(target=tmp_path) == 0 + out = capsys.readouterr().out + assert "dogfood_ready: False" in out + assert "latest_run: none" in out + assert "next_source: default_review" in out + assert f"next: {dogfood_cmd.DEFAULT_TASK}" in out + + def test_work_resume_empty_state(tmp_path, capsys): _init_git_repo(tmp_path) @@ -488,6 +519,46 @@ def fake_dogfood_run(task, **kwargs): assert "next: Build work run." in out +def test_work_run_uses_latest_next_when_task_is_omitted(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 consumed task.\n") + times = iter( + [ + 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)) + 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 consumed task." + session_dir = tmp_path / ".brigade" / "work" / "20260526-120000-build-consumed-task" + payload = json.loads((session_dir / "session.json").read_text()) + assert payload["title"] == "Build consumed task." + + def test_work_run_closes_session_when_dogfood_fails(tmp_path, monkeypatch): _init_git_repo(tmp_path) dogfood_cmd.init(target=tmp_path) @@ -542,6 +613,19 @@ def fake_resume(**kwargs): assert seen == {"target": tmp_path} +def test_work_next_cli(tmp_path, monkeypatch): + seen = {} + + def fake_next(**kwargs): + seen.update(kwargs) + return 0 + + monkeypatch.setattr(work_cmd, "next", fake_next) + + assert cli.main(["work", "next", "--target", str(tmp_path)]) == 0 + assert seen == {"target": tmp_path} + + def test_work_doctor_cli(tmp_path, monkeypatch): seen = {}