diff --git a/CHANGELOG.md b/CHANGELOG.md index a0052f69..8436cee2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `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 import add/list/show/promote` to manage a gitignored local import inbox for scanner-discovered candidate work. +- `brigade work import triage` to group pending imports by source and kind. +- `brigade work import dismiss` to close noisy imports without promoting them. +- `brigade work import promote --all` with optional `--source` and `--kind` filters for batch promotion. - `ROADMAP.md` covering the daily-driver path, scanner-ready inbox, chat-surface scanners, memory-card decay refresh, and portable operator setup. - `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. @@ -61,7 +64,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 includes pending local work imports in both text and JSON output. +- `brigade work brief` now includes pending local work imports and import counts in both text and JSON output. - 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 6a74acca..24de8f50 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,10 @@ brigade work task add --from-next brigade work task done brigade work import add --kind task --source slack "refresh the stale memory card" brigade work import list +brigade work import triage brigade work import promote +brigade work import promote --all --source memory-care --kind task +brigade work import dismiss --reason "not actionable" brigade work run brigade work run --queue-next brigade work run "review today's changes" @@ -183,7 +186,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, pending local imports, 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 import add "..."` as a scanner-ready local inbox for candidate tasks, findings, decisions, preferences, incidents, links, and commands from systems such as chat crawlers or memory-care decay scans; imports are stored under `.brigade/work/imports/inbox.jsonl`, stay gitignored, and do not write memory directly. Use `brigade work import list`, `brigade work import show `, and `brigade work import promote ` to review and promote an import into the task ledger with source metadata preserved. 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`. +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, pending local import counts by source and kind, 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 import add "..."` as a scanner-ready local inbox for candidate tasks, findings, decisions, preferences, incidents, links, and commands from systems such as chat crawlers or memory-care decay scans; imports are stored under `.brigade/work/imports/inbox.jsonl`, stay gitignored, and do not write memory directly. Use `brigade work import triage` to group pending imports by source and kind, `brigade work import show ` to inspect one item, `brigade work import dismiss ` to remove noise from the pending queue, `brigade work import promote ` to promote one reviewed import into the task ledger, or `brigade work import promote --all --source memory-care --kind task` to batch-promote filtered pending imports with source metadata preserved. 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/ROADMAP.md b/ROADMAP.md index 112ac70a..729059b2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -16,13 +16,17 @@ Status: in progress. ## Next Phase: Scanner-Ready Inbox +Status: in progress. + Goal: make Brigade a safe target for local automations that discover useful work. - Keep raw scanner output private and gitignored under `.brigade/work/imports/`. - Normalize imports into small records with `kind`, `source`, text, timestamps, and metadata. - Let wrappers import candidate tasks, findings, decisions, preferences, incidents, links, and commands without knowing Brigade internals. - Promote selected imports into the work task ledger, with source metadata preserved. -- Surface pending imports in `brigade work brief` so discovered work appears in the daily flow. +- Dismiss noisy imports so scanners can be useful without leaving permanent queue clutter. +- Batch-promote reviewed imports by source and kind. +- Surface pending imports and grouped counts in `brigade work brief` so discovered work appears in the daily flow. ## Later Phase: Chat Surface Scanners diff --git a/src/brigade/cli.py b/src/brigade/cli.py index 45b9f556..3cf9f525 100644 --- a/src/brigade/cli.py +++ b/src/brigade/cli.py @@ -180,12 +180,28 @@ def _build_parser() -> argparse.ArgumentParser: p_work_import_list.add_argument("--all", action="store_true", help="Include promoted imports.") p_work_import_list.add_argument("--json", action="store_true", help="Print machine-readable JSON.") p_work_import_list.add_argument("--limit", type=int, default=20, help="Maximum imports to show.") + p_work_import_triage = import_sub.add_parser("triage", help="Group pending imports by source and kind.") + p_work_import_triage.add_argument("--target", "-t", type=Path, default=Path("."), help="Repo or workspace to inspect.") + p_work_import_triage.add_argument("--json", action="store_true", help="Print machine-readable JSON.") + p_work_import_triage.add_argument("--limit", type=int, default=50, help="Maximum imports per group to show.") p_work_import_show = import_sub.add_parser("show", help="Show one work import.") p_work_import_show.add_argument("import_id", help="Import id or unique prefix.") p_work_import_show.add_argument("--target", "-t", type=Path, default=Path("."), help="Repo or workspace to inspect.") p_work_import_promote = import_sub.add_parser("promote", help="Promote one work import into the task ledger.") - p_work_import_promote.add_argument("import_id", help="Import id or unique prefix.") + p_work_import_promote.add_argument("import_id", nargs="?", help="Import id or unique prefix.") p_work_import_promote.add_argument("--target", "-t", type=Path, default=Path("."), help="Repo or workspace to update.") + p_work_import_promote.add_argument("--all", action="store_true", help="Promote all pending imports matching filters.") + p_work_import_promote.add_argument( + "--kind", + choices=["task", "finding", "decision", "preference", "incident", "link", "command"], + default=None, + help="Limit --all promotion to one kind.", + ) + p_work_import_promote.add_argument("--source", default=None, help="Limit --all promotion to one source.") + p_work_import_dismiss = import_sub.add_parser("dismiss", help="Dismiss one pending work import.") + p_work_import_dismiss.add_argument("import_id", help="Import id or unique prefix.") + p_work_import_dismiss.add_argument("--target", "-t", type=Path, default=Path("."), help="Repo or workspace to update.") + p_work_import_dismiss.add_argument("--reason", default=None, help="Optional dismiss reason.") 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.") @@ -556,10 +572,20 @@ def main(argv=None) -> int: json_output=args.json, limit=args.limit, ) + if args.import_command == "triage": + return work_cmd.import_triage(target=args.target, json_output=args.json, limit=args.limit) if args.import_command == "show": return work_cmd.import_show(target=args.target, import_id=args.import_id) if args.import_command == "promote": - return work_cmd.import_promote(target=args.target, import_id=args.import_id) + return work_cmd.import_promote( + target=args.target, + import_id=args.import_id, + all_matching=args.all, + kind=args.kind, + source=args.source, + ) + if args.import_command == "dismiss": + return work_cmd.import_dismiss(target=args.target, import_id=args.import_id, reason=args.reason) parser.error(f"unknown import command: {args.import_command}") return 2 if args.work_command == "list": diff --git a/src/brigade/work_cmd.py b/src/brigade/work_cmd.py index b9964a5d..2e49f3c1 100644 --- a/src/brigade/work_cmd.py +++ b/src/brigade/work_cmd.py @@ -226,6 +226,35 @@ def _pending_imports(target: Path) -> list[dict[str, Any]]: return imports +def _import_counts(imports: list[dict[str, Any]]) -> dict[str, Any]: + by_source: dict[str, int] = {} + by_kind: dict[str, int] = {} + for item in imports: + source = str(item.get("source") or "manual") + kind = str(item.get("kind") or "task") + by_source[source] = by_source.get(source, 0) + 1 + by_kind[kind] = by_kind.get(kind, 0) + 1 + return { + "total": len(imports), + "by_source": dict(sorted(by_source.items())), + "by_kind": dict(sorted(by_kind.items())), + } + + +def _matching_pending_imports( + target: Path, + *, + kind: str | None = None, + source: str | None = None, +) -> list[dict[str, Any]]: + imports = _pending_imports(target) + if kind: + imports = [item for item in imports if item.get("kind") == kind] + if source: + imports = [item for item in imports if item.get("source") == source] + return imports + + def _find_pending_task_by_text(target: Path, text: str) -> dict[str, Any] | None: wanted = _task_text_key(text) if not wanted: @@ -251,6 +280,29 @@ def _find_import(target: Path, import_id: str) -> tuple[dict[str, Any] | None, l return None, imports +def _mark_import_promoted(target: Path, item: dict[str, Any]) -> tuple[dict[str, Any], bool]: + text = str(item.get("text") or "").strip() + metadata: dict[str, Any] = { + "import_id": item.get("id"), + "import_kind": item.get("kind"), + "import_source": item.get("source"), + } + item_metadata = item.get("metadata") if isinstance(item.get("metadata"), dict) else {} + metadata.update(item_metadata) + task, created = _add_task( + target, + text, + source=f"import:{item.get('source') or 'manual'}", + metadata=metadata, + ) + now = _now().isoformat() + item["status"] = "promoted" + item["updated_at"] = now + item["promoted_at"] = now + item["task_id"] = task["id"] + return task, created + + 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]] = [] @@ -776,6 +828,7 @@ def _brief_payload(target: Path, *, limit: int = 3) -> dict[str, Any]: suggested = _suggested_command(active, resolved["task"], resolved["source"]) pending = _pending_tasks(target) pending_imports = _pending_imports(target) + pending_import_counts = _import_counts(pending_imports) return { "target": str(target), "git": git, @@ -787,6 +840,7 @@ def _brief_payload(target: Path, *, limit: int = 3) -> dict[str, Any]: "pending_tasks": pending, "imports_path": str(_imports_path(target)), "pending_imports": pending_imports, + "pending_import_counts": pending_import_counts, "dogfood": resolved["dogfood"], "next_source": resolved["source"], "task_id": resolved.get("task_id"), @@ -1232,6 +1286,19 @@ def brief(*, target: Path, limit: int = 3, json_output: bool = False) -> int: pending_imports = payload["pending_imports"] if isinstance(pending_imports, list) and pending_imports: + counts = payload.get("pending_import_counts") + if isinstance(counts, dict): + print(f"pending_import_count: {counts.get('total', len(pending_imports))}") + by_source = counts.get("by_source") if isinstance(counts.get("by_source"), dict) else {} + if by_source: + print("pending_imports_by_source:") + for source, count in by_source.items(): + print(f" {source}: {count}") + by_kind = counts.get("by_kind") if isinstance(counts.get("by_kind"), dict) else {} + if by_kind: + print("pending_imports_by_kind:") + for kind, count in by_kind.items(): + print(f" {kind}: {count}") print("pending_imports:") for item in pending_imports[:5]: if not isinstance(item, dict): @@ -1439,6 +1506,54 @@ def import_list(*, target: Path, all_imports: bool = False, json_output: bool = return 0 +def import_triage(*, target: Path, json_output: bool = False, limit: int = 50) -> int: + if limit < 1: + print("error: --limit must be a positive integer", file=sys.stderr) + return 2 + target = target.expanduser().resolve() + if not target.is_dir(): + print(f"error: --target is not a directory: {target}", file=sys.stderr) + return 2 + pending = _pending_imports(target) + counts = _import_counts(pending) + groups: dict[str, dict[str, list[dict[str, Any]]]] = {} + for item in pending: + source = str(item.get("source") or "manual") + kind = str(item.get("kind") or "task") + groups.setdefault(source, {}).setdefault(kind, []).append(item) + + if json_output: + print( + json.dumps( + { + "imports_path": str(_imports_path(target)), + "counts": counts, + "groups": groups, + }, + indent=2, + sort_keys=True, + ) + ) + return 0 + + print(f"work import triage: {target}") + print(f"imports_path: {_imports_path(target)}") + print(f"pending_imports: {counts['total']}") + if not pending: + return 0 + print("sources:") + for source, by_kind in sorted(groups.items()): + source_count = sum(len(items) for items in by_kind.values()) + print(f"- {source}: {source_count}") + for kind, items in sorted(by_kind.items()): + print(f" {kind}: {len(items)}") + for item in items[:limit]: + print(f" - {item.get('id')} {_short(str(item.get('text', '')))}") + if len(items) > limit: + print(f" ... {len(items) - limit} more") + return 0 + + def import_show(*, target: Path, import_id: str) -> int: target = target.expanduser().resolve() if not target.is_dir(): @@ -1467,11 +1582,48 @@ def import_show(*, target: Path, import_id: str) -> int: return 0 -def import_promote(*, target: Path, import_id: str) -> int: +def import_promote( + *, + target: Path, + import_id: str | None = None, + all_matching: bool = False, + kind: str | None = None, + source: 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) return 2 + if kind is not None and kind not in IMPORT_KINDS: + print(f"error: --kind must be one of: {', '.join(IMPORT_KINDS)}", file=sys.stderr) + return 2 + if all_matching and import_id: + print("error: pass an import id or --all, not both", file=sys.stderr) + return 2 + if all_matching: + imports = _read_imports(target) + wanted_ids = {item.get("id") for item in _matching_pending_imports(target, kind=kind, source=source)} + promoted: list[tuple[dict[str, Any], dict[str, Any], bool]] = [] + for item in imports: + if item.get("id") not in wanted_ids: + continue + text = str(item.get("text") or "").strip() + if not text: + continue + task, created = _mark_import_promoted(target, item) + promoted.append((item, task, created)) + _write_imports(target, imports) + created_count = len([item for item in promoted if item[2]]) + print(f"promoted: {len(promoted)}") + print(f"created: {created_count}") + print(f"existing: {len(promoted) - created_count}") + for item, task, created in promoted: + status = "created" if created else "existing" + print(f"- {item.get('id')} -> {task['id']} [{status}] {_short(str(task.get('text', '')))}") + return 0 + if not import_id: + print("error: import id is required unless --all is passed", file=sys.stderr) + return 2 item, imports = _find_import(target, import_id) if item is None: print(f"error: import not found: {import_id}", file=sys.stderr) @@ -1480,24 +1632,7 @@ def import_promote(*, target: Path, import_id: str) -> int: if not text: print(f"error: import has no text: {import_id}", file=sys.stderr) return 2 - metadata: dict[str, Any] = { - "import_id": item.get("id"), - "import_kind": item.get("kind"), - "import_source": item.get("source"), - } - item_metadata = item.get("metadata") if isinstance(item.get("metadata"), dict) else {} - metadata.update(item_metadata) - task, created = _add_task( - target, - text, - source=f"import:{item.get('source') or 'manual'}", - metadata=metadata, - ) - now = _now().isoformat() - item["status"] = "promoted" - item["updated_at"] = now - item["promoted_at"] = now - item["task_id"] = task["id"] + task, created = _mark_import_promoted(target, item) _write_imports(target, imports) print(f"import: {item.get('id')}") print(f"status: {item.get('status')}") @@ -1507,6 +1642,29 @@ def import_promote(*, target: Path, import_id: str) -> int: return 0 +def import_dismiss(*, target: Path, import_id: str, reason: 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) + return 2 + item, imports = _find_import(target, import_id) + if item is None: + print(f"error: import not found: {import_id}", file=sys.stderr) + return 1 + now = _now().isoformat() + item["status"] = "dismissed" + item["updated_at"] = now + item["dismissed_at"] = now + if reason and reason.strip(): + item["dismiss_reason"] = reason.strip() + _write_imports(target, imports) + print(f"import: {item.get('id')}") + print("status: dismissed") + if item.get("dismiss_reason"): + print(f"reason: {item['dismiss_reason']}") + return 0 + + def next(*, target: Path, json_output: bool = False) -> int: target = target.expanduser().resolve() if not target.is_dir(): diff --git a/tests/test_work_cmd.py b/tests/test_work_cmd.py index 60f50955..e150c9bb 100644 --- a/tests/test_work_cmd.py +++ b/tests/test_work_cmd.py @@ -649,6 +649,92 @@ def test_work_import_promote_reuses_existing_pending_task(tmp_path, monkeypatch, assert len(ledger["tasks"]) == 1 +def test_work_import_triage_groups_pending_imports(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.import_add(target=tmp_path, text="Refresh card", kind="task", source="memory-care") == 0 + assert work_cmd.import_add(target=tmp_path, text="Check chat decision", kind="decision", source="slack") == 0 + assert work_cmd.import_add(target=tmp_path, text="Review chat task", kind="task", source="slack") == 0 + capsys.readouterr() + + assert work_cmd.import_triage(target=tmp_path) == 0 + out = capsys.readouterr().out + assert "work import triage:" in out + assert "pending_imports: 3" in out + assert "- memory-care: 1" in out + assert " task: 1" in out + assert "- slack: 2" in out + assert " decision: 1" in out + assert "Review chat task" in out + + assert work_cmd.import_triage(target=tmp_path, json_output=True) == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["counts"]["total"] == 3 + assert payload["counts"]["by_source"] == {"memory-care": 1, "slack": 2} + assert payload["counts"]["by_kind"] == {"decision": 1, "task": 2} + assert payload["groups"]["slack"]["decision"][0]["text"] == "Check chat decision" + + +def test_work_import_promote_all_filters_by_source_and_kind(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.import_add(target=tmp_path, text="Refresh card one", kind="task", source="memory-care") == 0 + assert work_cmd.import_add(target=tmp_path, text="Refresh card two", kind="task", source="memory-care") == 0 + assert work_cmd.import_add(target=tmp_path, text="Review chat note", kind="task", source="slack") == 0 + assert work_cmd.import_add(target=tmp_path, text="Record decision", kind="decision", source="memory-care") == 0 + capsys.readouterr() + + assert ( + work_cmd.import_promote( + target=tmp_path, + all_matching=True, + kind="task", + source="memory-care", + ) + == 0 + ) + out = capsys.readouterr().out + assert "promoted: 2" in out + assert "created: 2" in out + assert "existing: 0" in out + ledger = json.loads((tmp_path / ".brigade" / "work" / "tasks.json").read_text()) + assert [task["text"] for task in ledger["tasks"]] == ["Refresh card one", "Refresh card two"] + + assert work_cmd.import_list(target=tmp_path, json_output=True) == 0 + payload = json.loads(capsys.readouterr().out) + assert [item["text"] for item in payload["imports"]] == ["Review chat note", "Record decision"] + + +def test_work_import_dismiss_marks_import_not_pending(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.import_add(target=tmp_path, text="Ignore noisy scanner item", source="discord") == 0 + import_id = capsys.readouterr().out.split("import: ", 1)[1].splitlines()[0] + + assert work_cmd.import_dismiss(target=tmp_path, import_id=import_id[:12], reason="not actionable") == 0 + out = capsys.readouterr().out + assert "status: dismissed" in out + assert "reason: not actionable" in out + assert work_cmd.import_list(target=tmp_path) == 0 + assert "imports: none" in capsys.readouterr().out + assert work_cmd.import_list(target=tmp_path, all_imports=True, json_output=True) == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["imports"][0]["status"] == "dismissed" + assert payload["imports"][0]["dismiss_reason"] == "not actionable" + + def test_work_brief_includes_pending_imports(tmp_path, monkeypatch, capsys): _init_git_repo(tmp_path) monkeypatch.setattr( @@ -673,6 +759,17 @@ def test_work_brief_includes_pending_imports(tmp_path, monkeypatch, capsys): assert payload["pending_imports"][0]["text"] == "Review expired decision card" assert payload["pending_imports"][0]["kind"] == "finding" assert payload["pending_imports"][0]["source"] == "memory-care" + assert payload["pending_import_counts"]["total"] == 1 + assert payload["pending_import_counts"]["by_source"] == {"memory-care": 1} + assert payload["pending_import_counts"]["by_kind"] == {"finding": 1} + + assert work_cmd.brief(target=tmp_path) == 0 + out = capsys.readouterr().out + assert "pending_import_count: 1" in out + assert "pending_imports_by_source:" in out + assert " memory-care: 1" in out + assert "pending_imports_by_kind:" in out + assert " finding: 1" in out def test_work_next_reports_latest_next_as_default_task(tmp_path, monkeypatch, capsys): @@ -1157,6 +1254,10 @@ def fake_import_list(**kwargs): seen.append(("list", kwargs)) return 0 + def fake_import_triage(**kwargs): + seen.append(("triage", kwargs)) + return 0 + def fake_import_show(**kwargs): seen.append(("show", kwargs)) return 0 @@ -1165,10 +1266,16 @@ def fake_import_promote(**kwargs): seen.append(("promote", kwargs)) return 0 + def fake_import_dismiss(**kwargs): + seen.append(("dismiss", kwargs)) + return 0 + monkeypatch.setattr(work_cmd, "import_add", fake_import_add) monkeypatch.setattr(work_cmd, "import_list", fake_import_list) + monkeypatch.setattr(work_cmd, "import_triage", fake_import_triage) monkeypatch.setattr(work_cmd, "import_show", fake_import_show) monkeypatch.setattr(work_cmd, "import_promote", fake_import_promote) + monkeypatch.setattr(work_cmd, "import_dismiss", fake_import_dismiss) assert ( cli.main( @@ -1191,8 +1298,26 @@ def fake_import_promote(**kwargs): == 0 ) assert cli.main(["work", "import", "list", "--target", str(tmp_path), "--all", "--json", "--limit", "3"]) == 0 + assert cli.main(["work", "import", "triage", "--target", str(tmp_path), "--json", "--limit", "4"]) == 0 assert cli.main(["work", "import", "show", "imp123", "--target", str(tmp_path)]) == 0 - assert cli.main(["work", "import", "promote", "imp123", "--target", str(tmp_path)]) == 0 + assert ( + cli.main( + [ + "work", + "import", + "promote", + "--target", + str(tmp_path), + "--all", + "--kind", + "task", + "--source", + "memory-care", + ] + ) + == 0 + ) + assert cli.main(["work", "import", "dismiss", "imp123", "--target", str(tmp_path), "--reason", "noise"]) == 0 assert seen == [ ( "add", @@ -1205,8 +1330,19 @@ def fake_import_promote(**kwargs): }, ), ("list", {"target": tmp_path, "all_imports": True, "json_output": True, "limit": 3}), + ("triage", {"target": tmp_path, "json_output": True, "limit": 4}), ("show", {"target": tmp_path, "import_id": "imp123"}), - ("promote", {"target": tmp_path, "import_id": "imp123"}), + ( + "promote", + { + "target": tmp_path, + "import_id": None, + "all_matching": True, + "kind": "task", + "source": "memory-care", + }, + ), + ("dismiss", {"target": tmp_path, "import_id": "imp123", "reason": "noise"}), ]