diff --git a/CHANGELOG.md b/CHANGELOG.md index edc12ecc..48d29dc4 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`. +- Typed task metadata and repeatable acceptance criteria for `brigade work task add`, plus `brigade work task plan` for the completion checklist. - `brigade work run --queue-next` to queue the successful run's extracted next step, with duplicate pending task protection. - `brigade work import add/list/show/promote` to manage a gitignored local import inbox for scanner-discovered candidate work. - `brigade work import validate` and `brigade work import ingest` for scanner-authored JSONL import files. @@ -92,6 +93,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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. +- `brigade work brief` now reports acceptance coverage for the next ledger task, and `brigade work run` passes accepted ledger criteria into the dogfood task prompt. - `brigade work brief` now includes pending local work imports and import counts in both text and JSON output. - `brigade work brief` now surfaces pending handoff ingest issue counts when the local handoff source config has an ingestor latest-run log. - The managed gitignore block now treats `.brigade/dogfood.toml`, `.brigade/security.toml`, `.brigade/runs/`, and `.brigade/security/` as local state. diff --git a/README.md b/README.md index 96b6b818..086af91e 100644 --- a/README.md +++ b/README.md @@ -170,7 +170,9 @@ brigade work next brigade work next --json brigade work tasks brigade work task add "build the next slice" +brigade work task add "build the next slice" --type feature --priority high --acceptance "focused tests pass" brigade work task add --from-next +brigade work task plan brigade work task done brigade work import add --kind task --source slack "refresh the stale memory card" brigade work import list @@ -276,7 +278,9 @@ Task ledger commands: - `brigade work tasks` lists `.brigade/work/tasks.json`. - `brigade work task add "..."` queues a task manually. +- `brigade work task add "..." --type feature --priority high --acceptance "..."` queues typed work with repeatable acceptance criteria. - `brigade work task add --from-next` promotes the latest extracted dogfood next step. +- `brigade work task plan ` shows the task metadata, acceptance checklist, and suggested run command. - `brigade work task done ` closes queued work. Import inbox commands: @@ -297,6 +301,7 @@ For handoff-ingest issues, prefer `brigade handoff sync-issues` over repeated ra Run the daily loop with `brigade work run`. It opens a work session, resolves the next task, runs `brigade dogfood`, and closes completed ledger tasks after successful runs. +When the resolved ledger task has acceptance criteria, `work run` includes them in the task prompt as the definition of done. Then it ends the session, writes a work-session Memory Handoff by default, and prints a recap. Useful `work run` switches: diff --git a/ROADMAP.md b/ROADMAP.md index 0b5aec36..da141037 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -99,7 +99,7 @@ Brigade-specific additions: Goal: make Brigade support a narrow issue lifecycle for daily work: pick one task, define acceptance, test first when practical, implement, review, refactor, and close. - Add task templates for vertical-slice work, bugfix work, and RED/GREEN/REFACTOR loops. -- Let `brigade work run` consume structured acceptance criteria from the local task ledger or a GitHub issue mirror. +- Let `brigade work run` consume structured acceptance criteria from the local task ledger or a GitHub issue mirror. Status: started for local ledger tasks with typed priority, repeatable acceptance criteria, `work task plan`, brief coverage, and criteria injection into ledger-driven runs. - Keep repo-shareable workflow rules separate from gitignored personal/global preferences. - Add doctor checks for missing acceptance criteria or stale active issue context. diff --git a/src/brigade/cli.py b/src/brigade/cli.py index de339ddd..0eb997d0 100644 --- a/src/brigade/cli.py +++ b/src/brigade/cli.py @@ -7,6 +7,7 @@ from . import __version__ from .dogfood_cmd import DEFAULT_TIMEOUT_SECONDS +from .work_cmd import TASK_PRIORITIES, TASK_TYPES from .prompt import prompt_for_selection # imported here so tests can monkeypatch cli.prompt_for_selection @@ -182,9 +183,21 @@ def _build_parser() -> argparse.ArgumentParser: 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_add.add_argument("--type", choices=TASK_TYPES, default="task", help="Task type.") + p_work_task_add.add_argument("--priority", choices=TASK_PRIORITIES, default="normal", help="Task priority.") + p_work_task_add.add_argument( + "--acceptance", + action="append", + default=[], + help="Acceptance criterion. Repeat for multiple criteria.", + ) 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_plan = task_sub.add_parser("plan", help="Show task acceptance criteria and run plan.") + p_work_task_plan.add_argument("task_id", help="Task id or unique prefix.") + p_work_task_plan.add_argument("--target", "-t", type=Path, default=Path("."), help="Repo or workspace to inspect.") + p_work_task_plan.add_argument("--json", action="store_true", help="Print machine-readable JSON.") 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.") @@ -748,9 +761,18 @@ def main(argv=None) -> int: 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) + return work_cmd.task_add( + target=args.target, + text=text, + from_next=args.from_next, + task_type=args.type, + priority=args.priority, + acceptance=args.acceptance, + ) if args.task_command == "show": return work_cmd.task_show(target=args.target, task_id=args.task_id) + if args.task_command == "plan": + return work_cmd.task_plan(target=args.target, task_id=args.task_id, json_output=args.json) 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}") diff --git a/src/brigade/work_cmd.py b/src/brigade/work_cmd.py index 1143ef69..0d718493 100644 --- a/src/brigade/work_cmd.py +++ b/src/brigade/work_cmd.py @@ -20,6 +20,8 @@ WARN = "warn" FAIL = "fail" IMPORT_KINDS = ("task", "finding", "decision", "preference", "incident", "link", "command") +TASK_TYPES = ("task", "feature", "bug", "docs", "security", "workflow", "research", "chore") +TASK_PRIORITIES = ("low", "normal", "high", "urgent") def _git(target: Path, *args: str) -> subprocess.CompletedProcess[str]: @@ -199,6 +201,59 @@ def _task_text_key(text: str) -> str: return " ".join(text.casefold().split()) +def _normalize_task_type(value: object) -> str: + if isinstance(value, str) and value.strip() in TASK_TYPES: + return value.strip() + return "task" + + +def _normalize_task_priority(value: object) -> str: + if isinstance(value, str) and value.strip() in TASK_PRIORITIES: + return value.strip() + return "normal" + + +def _normalize_acceptance(values: object) -> list[str]: + if values is None: + return [] + raw_values = values if isinstance(values, list) else [values] + accepted: list[str] = [] + seen: set[str] = set() + for value in raw_values: + text = str(value).strip() + if not text: + continue + key = _task_text_key(text) + if key in seen: + continue + accepted.append(text) + seen.add(key) + return accepted + + +def _task_acceptance(task: dict[str, Any]) -> list[str]: + values = task.get("acceptance") + if values is None: + metadata = task.get("metadata") if isinstance(task.get("metadata"), dict) else {} + values = metadata.get("acceptance") + return _normalize_acceptance(values) + + +def _task_summary(task: dict[str, Any]) -> dict[str, Any]: + acceptance = _task_acceptance(task) + return { + "id": task.get("id"), + "text": str(task.get("text") or ""), + "status": task.get("status", "pending"), + "source": task.get("source", "manual"), + "type": _normalize_task_type(task.get("type")), + "priority": _normalize_task_priority(task.get("priority")), + "acceptance": acceptance, + "acceptance_count": len(acceptance), + "acceptance_missing": len(acceptance) == 0, + } + + def _import_record_key(item: dict[str, Any]) -> tuple[str, str, str]: return ( str(item.get("source") or "manual"), @@ -448,7 +503,15 @@ 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", metadata: dict[str, Any] | None = None) -> dict[str, Any]: +def _make_task( + text: str, + *, + source: str = "manual", + metadata: dict[str, Any] | None = None, + task_type: str = "task", + priority: str = "normal", + acceptance: list[str] | None = None, +) -> dict[str, Any]: now = _now() created = now.isoformat() task = { @@ -456,6 +519,9 @@ def _make_task(text: str, *, source: str = "manual", metadata: dict[str, Any] | "text": text, "status": "pending", "source": source, + "type": _normalize_task_type(task_type), + "priority": _normalize_task_priority(priority), + "acceptance": _normalize_acceptance(acceptance), "created_at": created, "updated_at": created, } @@ -506,12 +572,22 @@ def _add_task( *, source: str = "manual", metadata: dict[str, Any] | None = None, + task_type: str = "task", + priority: str = "normal", + acceptance: list[str] | None = None, ) -> tuple[dict[str, Any], bool]: ledger = _read_task_ledger(target) existing = _find_pending_task_by_text(target, text) if existing is not None: return existing, False - task = _make_task(text, source=source, metadata=metadata) + task = _make_task( + text, + source=source, + metadata=metadata, + task_type=task_type, + priority=priority, + acceptance=acceptance, + ) ledger["tasks"].append(task) _write_task_ledger(target, ledger) return task, True @@ -700,6 +776,43 @@ def _resolve_next_task(target: Path) -> dict[str, Any]: } +def _render_task_run_prompt(task: dict[str, Any]) -> str: + text = str(task.get("text") or "").strip() + lines = [text] + acceptance = _task_acceptance(task) + if acceptance: + lines.extend(["", "Acceptance criteria:"]) + lines.extend(f"- {item}" for item in acceptance) + lines.extend( + [ + "", + "Task metadata:", + f"- type: {_normalize_task_type(task.get('type'))}", + f"- priority: {_normalize_task_priority(task.get('priority'))}", + "", + "Definition of done:", + "- Treat the acceptance criteria above as the completion checklist.", + "- Report the verification command you ran, or explain the blocker.", + ] + ) + return "\n".join(lines).strip() + + +def _task_plan_payload(target: Path, task_id: str) -> tuple[dict[str, Any] | None, int]: + target = target.expanduser().resolve() + if not target.is_dir(): + print(f"error: --target is not a directory: {target}", file=sys.stderr) + return None, 2 + task, _ = _find_task(target, task_id) + if task is None: + print(f"error: task not found: {task_id}", file=sys.stderr) + return None, 1 + summary = _task_summary(task) + summary["suggested_command"] = "brigade work run" + summary["tasks_path"] = str(_tasks_path(target)) + return summary, 0 + + def _display_session(path: Path, payload: dict[str, Any]) -> None: print(f"session: {path}") print(f"id: {payload.get('id', path.name)}") @@ -925,6 +1038,7 @@ def _next_payload(target: Path) -> dict[str, Any]: active = _active_session_info(target) resolved = _resolve_next_task(target) dogfood = resolved["dogfood"] + ledger_task = resolved.get("ledger_task") if isinstance(resolved.get("ledger_task"), dict) else None suggested = 'brigade work end --note "..." --handoff' if active is not None else "brigade work run" return { "target": str(target), @@ -932,6 +1046,7 @@ def _next_payload(target: Path) -> dict[str, Any]: "dogfood": dogfood, "next_source": resolved["source"], "task_id": resolved.get("task_id"), + "next_task": _task_summary(ledger_task) if ledger_task else None, "next": str(resolved["task"]), "suggested_command": suggested, } @@ -956,6 +1071,7 @@ def _brief_payload(target: Path, *, limit: int = 3) -> dict[str, Any]: 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) + ledger_task = resolved.get("ledger_task") if isinstance(resolved.get("ledger_task"), dict) else None git = _git_snapshot(target) suggested = _suggested_command(active, resolved["task"], resolved["source"]) pending = _pending_tasks(target) @@ -988,6 +1104,7 @@ def _brief_payload(target: Path, *, limit: int = 3) -> dict[str, Any]: "dogfood": resolved["dogfood"], "next_source": resolved["source"], "task_id": resolved.get("task_id"), + "next_task": _task_summary(ledger_task) if ledger_task else None, "next": str(resolved["task"]), "suggested_command": suggested, } @@ -1415,6 +1532,14 @@ def brief(*, target: Path, limit: int = 3, json_output: bool = False) -> int: print(f"next_source: {payload['next_source']}") if payload.get("task_id"): print(f"task_id: {payload['task_id']}") + next_task = payload.get("next_task") if isinstance(payload.get("next_task"), dict) else None + if next_task: + print(f"next_type: {next_task.get('type')}") + print(f"next_priority: {next_task.get('priority')}") + if next_task.get("acceptance_missing"): + print("next_acceptance: missing") + else: + print(f"next_acceptance: {next_task.get('acceptance_count')}") print(f"next: {_short(str(payload['next']))}") print(f"suggested_command: {payload['suggested_command']}") @@ -1424,7 +1549,13 @@ def brief(*, target: Path, limit: int = 3, json_output: bool = False) -> int: for task in pending[:5]: if not isinstance(task, dict): continue - print(f" - {task.get('id')} {_short(str(task.get('text', '')))}") + summary = _task_summary(task) + print( + " - " + f"{task.get('id')} " + f"[{summary['type']} {summary['priority']} acceptance={summary['acceptance_count']}] " + f"{_short(str(task.get('text', '')))}" + ) if len(pending) > 5: print(f" ... {len(pending) - 5} more") @@ -1499,7 +1630,12 @@ def tasks(*, target: Path, all_tasks: bool = False, json_output: bool = False) - 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', '')))}") + summary = _task_summary(task) + print( + f"- {task.get('id')} [{status_text}] " + f"[{summary['type']} {summary['priority']} acceptance={summary['acceptance_count']}] " + f"{_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 {} @@ -1512,7 +1648,15 @@ def tasks(*, target: Path, all_tasks: bool = False, json_output: bool = False) - return 0 -def task_add(*, target: Path, text: str | None = None, from_next: bool = False) -> int: +def task_add( + *, + target: Path, + text: str | None = None, + from_next: bool = False, + task_type: str = "task", + priority: str = "normal", + acceptance: list[str] | None = None, +) -> int: target = target.expanduser().resolve() if not target.is_dir(): print(f"error: --target is not a directory: {target}", file=sys.stderr) @@ -1520,6 +1664,12 @@ def task_add(*, target: Path, text: str | None = None, from_next: bool = False) if from_next and text: print("error: pass task text or --from-next, not both", file=sys.stderr) return 2 + if task_type not in TASK_TYPES: + print(f"error: --type must be one of: {', '.join(TASK_TYPES)}", file=sys.stderr) + return 2 + if priority not in TASK_PRIORITIES: + print(f"error: --priority must be one of: {', '.join(TASK_PRIORITIES)}", file=sys.stderr) + return 2 task_text = (text or "").strip() source = "manual" if from_next: @@ -1534,10 +1684,22 @@ def task_add(*, target: Path, text: str | None = None, from_next: bool = False) if not task_text: print("error: task text is required", file=sys.stderr) return 2 - task, created = _add_task(target, task_text, source=source, metadata=metadata) + task, created = _add_task( + target, + task_text, + source=source, + metadata=metadata, + task_type=task_type, + priority=priority, + acceptance=_normalize_acceptance(acceptance), + ) print(f"task: {task['id']}") print(f"status: {task['status']}") print(f"created: {created}") + print(f"type: {_normalize_task_type(task.get('type'))}") + print(f"priority: {_normalize_task_priority(task.get('priority'))}") + criteria = _task_acceptance(task) + print(f"acceptance: {len(criteria)}") print(f"text: {task['text']}") return 0 @@ -1554,8 +1716,14 @@ def task_show(*, target: Path, task_id: str) -> int: print(f"task: {task.get('id')}") print(f"status: {task.get('status', 'pending')}") print(f"source: {task.get('source', '')}") + print(f"type: {_normalize_task_type(task.get('type'))}") + print(f"priority: {_normalize_task_priority(task.get('priority'))}") print(f"created_at: {task.get('created_at', '')}") print(f"updated_at: {task.get('updated_at', '')}") + criteria = _task_acceptance(task) + print(f"acceptance: {len(criteria)}") + for item in criteria: + print(f" - {item}") metadata = task.get("metadata") if isinstance(task.get("metadata"), dict) else {} if metadata: print("metadata:") @@ -1567,6 +1735,29 @@ def task_show(*, target: Path, task_id: str) -> int: return 0 +def task_plan(*, target: Path, task_id: str, json_output: bool = False) -> int: + payload, rc = _task_plan_payload(target, task_id) + if payload is None: + return rc + if json_output: + print(json.dumps(payload, indent=2, sort_keys=True)) + return 0 + print(f"task: {payload['id']}") + print(f"type: {payload['type']}") + print(f"priority: {payload['priority']}") + print(f"status: {payload['status']}") + print(f"source: {payload['source']}") + print(f"text: {payload['text']}") + print("acceptance:") + if payload["acceptance"]: + for item in payload["acceptance"]: + print(f" - {item}") + else: + print(" missing") + print(f"suggested_command: {payload['suggested_command']}") + return 0 + + def task_done(*, target: Path, task_id: str) -> int: target = target.expanduser().resolve() if not target.is_dir(): @@ -2390,6 +2581,12 @@ 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 + ledger_task = resolved.get("ledger_task") if consumed_task_id and isinstance(resolved.get("ledger_task"), dict) else None + run_task_text = ( + _render_task_run_prompt(ledger_task) + if ledger_task is not None and _task_acceptance(ledger_task) + else task_text + ) session_title = title or task_text start_rc = start(target=target, title=session_title) if start_rc != 0: @@ -2399,7 +2596,7 @@ def run( dogfood_rc = 1 try: dogfood_rc = dogfood_cmd.run( - task_text, + run_task_text, target=target, output_dir=output_dir, handoff=dogfood_handoff, diff --git a/tests/test_work_cmd.py b/tests/test_work_cmd.py index f74fb3c6..e5f03607 100644 --- a/tests/test_work_cmd.py +++ b/tests/test_work_cmd.py @@ -550,12 +550,15 @@ def test_work_task_ledger_add_list_show_and_done(tmp_path, monkeypatch, capsys): out = capsys.readouterr().out assert "work tasks:" in out assert task_id in out - assert "[pending] Build task ledger" in out + assert "[pending] [task normal acceptance=0] 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 "type: task" in out + assert "priority: normal" in out + assert "acceptance: 0" in out assert "text: Build task ledger" in out assert work_cmd.task_done(target=tmp_path, task_id=task_id[:12]) == 0 @@ -597,6 +600,47 @@ def test_work_task_add_from_next_deduplicates_pending_task(tmp_path, monkeypatch assert payload["task_id"] +def test_work_task_add_stores_metadata_acceptance_and_plan(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 issue loop", + task_type="feature", + priority="high", + acceptance=["Adds metadata", "Shows criteria in the plan"], + ) + == 0 + ) + out = capsys.readouterr().out + task_id = out.split("task: ", 1)[1].splitlines()[0] + assert "type: feature" in out + assert "priority: high" in out + assert "acceptance: 2" in out + + assert work_cmd.task_plan(target=tmp_path, task_id=task_id[:12]) == 0 + out = capsys.readouterr().out + assert "task: " in out + assert "type: feature" in out + assert "priority: high" in out + assert " - Adds metadata" in out + assert " - Shows criteria in the plan" in out + assert "suggested_command: brigade work run" in out + + assert work_cmd.task_plan(target=tmp_path, task_id=task_id[:12], json_output=True) == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["type"] == "feature" + assert payload["priority"] == "high" + assert payload["acceptance_count"] == 2 + assert payload["acceptance_missing"] is False + + def test_work_brief_includes_pending_tasks(tmp_path, monkeypatch, capsys): _init_git_repo(tmp_path) monkeypatch.setattr( @@ -613,6 +657,35 @@ def test_work_brief_includes_pending_tasks(tmp_path, monkeypatch, capsys): assert payload["next"] == "Build queued task" assert payload["pending_tasks"][0]["text"] == "Build queued task" assert payload["suggested_command"] == "brigade work run" + assert payload["next_task"]["acceptance_missing"] is True + assert payload["next_task"]["acceptance_count"] == 0 + + +def test_work_brief_reports_next_task_acceptance(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 accepted task", + task_type="workflow", + priority="urgent", + acceptance=["Brief reports acceptance"], + ) + == 0 + ) + capsys.readouterr() + + assert work_cmd.brief(target=tmp_path) == 0 + out = capsys.readouterr().out + assert "next_type: workflow" in out + assert "next_priority: urgent" in out + assert "next_acceptance: 1" in out + assert "[workflow urgent acceptance=1] Build accepted task" in out def test_work_import_add_list_show_and_promote(tmp_path, monkeypatch, capsys): @@ -1511,6 +1584,55 @@ def fake_dogfood_run(task, **kwargs): assert ledger["tasks"][0]["completed_session_title"] == "Build queued task" +def test_work_run_passes_acceptance_criteria_for_pending_task(tmp_path, monkeypatch): + _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), + 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 accepted queue", + task_type="feature", + priority="high", + acceptance=["Dogfood prompt includes this criterion"], + ) + == 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"].startswith("Build accepted queue") + assert "Acceptance criteria:" in seen["task"] + assert "- Dogfood prompt includes this criterion" in seen["task"] + assert "- type: feature" in seen["task"] + assert "- priority: high" in seen["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 accepted queue" + + def test_work_run_queue_next_adds_extracted_followup(tmp_path, monkeypatch, capsys): _init_git_repo(tmp_path) artifacts_dir = tmp_path / ".brigade" / "runs" @@ -1701,6 +1823,10 @@ def fake_task_show(**kwargs): seen.append(("show", kwargs)) return 0 + def fake_task_plan(**kwargs): + seen.append(("plan", kwargs)) + return 0 + def fake_task_done(**kwargs): seen.append(("done", kwargs)) return 0 @@ -1708,18 +1834,60 @@ def fake_task_done(**kwargs): 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_plan", fake_task_plan) 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", + "build", + "queue", + "--target", + str(tmp_path), + "--type", + "feature", + "--priority", + "high", + "--acceptance", + "passes", + ] + ) + == 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", "plan", "abc123", "--target", str(tmp_path), "--json"]) == 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}), + ( + "add", + { + "target": tmp_path, + "text": "build queue", + "from_next": False, + "task_type": "feature", + "priority": "high", + "acceptance": ["passes"], + }, + ), + ( + "add", + { + "target": tmp_path, + "text": None, + "from_next": True, + "task_type": "task", + "priority": "normal", + "acceptance": [], + }, + ), ("show", {"target": tmp_path, "task_id": "abc123"}), + ("plan", {"target": tmp_path, "task_id": "abc123", "json_output": True}), ("done", {"target": tmp_path, "task_id": "abc123"}), ]