diff --git a/CHANGELOG.md b/CHANGELOG.md index fc747a99..a0052f69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `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 import add/list/show/promote` to manage a gitignored local import inbox for scanner-discovered candidate work. +- `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. - Workspace installs now include `.brigade/memory-care.example.json` as a scanner wiring contract for memory-care decay output. @@ -59,6 +61,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. - 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 b4de0a0c..6a74acca 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,8 @@ The cookbook explains the why. This package gives you the kitchen. Browse the public template index in [`templates/`](templates/). The installable source files live under `src/brigade/templates/`; root workspace files are local dogfood state and stay ignored. +See [`ROADMAP.md`](ROADMAP.md) for the daily-driver, scanner inbox, chat-surface scanner, and memory-card decay roadmap. + ## What you do not get - private hostnames, IPs, account IDs, or personal details @@ -148,6 +150,9 @@ brigade work tasks brigade work task add "build the next slice" 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 promote brigade work run brigade work run --queue-next brigade work run "review today's changes" @@ -178,7 +183,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. 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 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`. 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 new file mode 100644 index 00000000..112ac70a --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,56 @@ +# Brigade Roadmap + +Brigade is being built as a practical daily workflow first, then a portable setup other people can adapt. The core direction is an organized version of real agent work: one command to start, predictable local artifacts, reviewable memory handoffs, and enough inspection to trust the loop during normal work. + +## Current Phase: Daily Driver + +Status: in progress. + +- Local dogfood defaults live in gitignored `.brigade/dogfood.toml`. +- `brigade work bootstrap` prepares a repo for the daily loop. +- `brigade work brief` is the start-of-day entrypoint. +- `brigade work run` wraps a dogfood run in local work-session artifacts and handoffs. +- `brigade work tasks` plus `brigade work task add/show/done` provide a gitignored local task ledger. +- `brigade work run --queue-next` queues extracted follow-up work without duplicating equivalent pending tasks. +- `brigade work import add/list/show/promote` gives scanners and wrappers a stable local inbox for candidate work. + +## Next Phase: Scanner-Ready Inbox + +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. + +## Later Phase: Chat Surface Scanners + +Goal: support the common places agent work happens without making any one chat product mandatory. + +- Build adapters for Discord, Slack, Telegram, and export-based chat archives as separate scanner layers. +- Convert surface-specific events into the local import inbox instead of writing memory directly. +- Summarize private chat evidence, do not quote raw third-party messages into public docs or handoffs. +- Use promotion gates so only reviewed, durable, or actionable items become tasks or memory handoffs. +- Keep source metadata such as workspace, channel, thread, message range, and confidence local unless explicitly exported. + +## Later Phase: Memory Card Decay And Refresh + +Goal: prevent durable memory from silently rotting. + +- Track freshness metadata, confidence, evidence, and review dates for memory cards. +- Run memory-care scanners that detect expired, stale, contradictory, or undersourced cards. +- Import refresh candidates into Brigade as local work imports. +- Promote refresh candidates into tasks or memory handoffs after review. +- Auto-fix only within safe gates where source evidence is current, low-risk, and locally reviewable. +- Treat bootstrap truncation as a hard failure. Bootstrap files stay slim, cards hold durable detail, and doctor checks enforce the boundary. + +## Later Phase: Portable Operator Setup + +Goal: keep the system usable by the original operator while making it adaptable by others. + +- Keep Codex-first defaults, with Claude Code, OpenCode, Hermes, OpenClaw, and generic harness paths supported through writer-specific inboxes. +- Make local paths configurable and gitignored. +- Provide templates for fresh-start users without publishing private workspace state. +- Keep public repo docs focused on patterns, commands, and safety contracts. +- Leave release, tag, push-to-main, and production-impacting actions behind explicit approval gates. diff --git a/src/brigade/cli.py b/src/brigade/cli.py index cb85678b..45b9f556 100644 --- a/src/brigade/cli.py +++ b/src/brigade/cli.py @@ -156,6 +156,36 @@ def _build_parser() -> argparse.ArgumentParser: p_work_task_done = task_sub.add_parser("done", help="Mark one work task done.") p_work_task_done.add_argument("task_id", help="Task id or unique prefix.") p_work_task_done.add_argument("--target", "-t", type=Path, default=Path("."), help="Repo or workspace to update.") + p_work_import = work_sub.add_parser("import", help="Add, list, show, or promote scanner-ready work imports.") + import_sub = p_work_import.add_subparsers(dest="import_command", metavar="") + import_sub.required = True + p_work_import_add = import_sub.add_parser("add", help="Add a local work import.") + p_work_import_add.add_argument("text", nargs="+", help="Import text.") + p_work_import_add.add_argument("--target", "-t", type=Path, default=Path("."), help="Repo or workspace to update.") + p_work_import_add.add_argument( + "--kind", + choices=["task", "finding", "decision", "preference", "incident", "link", "command"], + default="task", + help="Import kind.", + ) + p_work_import_add.add_argument("--source", default="manual", help="Import source such as slack, discord, or memory-care.") + p_work_import_add.add_argument( + "--metadata", + action="append", + default=[], + help="Metadata as key=value. May be repeated.", + ) + p_work_import_list = import_sub.add_parser("list", help="List local work imports.") + p_work_import_list.add_argument("--target", "-t", type=Path, default=Path("."), help="Repo or workspace to inspect.") + 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_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("--target", "-t", type=Path, default=Path("."), help="Repo or workspace to update.") p_work_list = work_sub.add_parser("list", help="List recent Brigade work sessions.") p_work_list.add_argument("--target", "-t", type=Path, default=Path("."), help="Repo or workspace to inspect.") p_work_list.add_argument("--limit", type=int, default=10, help="Maximum sessions to show.") @@ -510,6 +540,28 @@ def main(argv=None) -> int: return work_cmd.task_done(target=args.target, task_id=args.task_id) parser.error(f"unknown task command: {args.task_command}") return 2 + if args.work_command == "import": + if args.import_command == "add": + return work_cmd.import_add( + target=args.target, + text=" ".join(args.text), + kind=args.kind, + source=args.source, + metadata=args.metadata, + ) + if args.import_command == "list": + return work_cmd.import_list( + target=args.target, + all_imports=args.all, + 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) + parser.error(f"unknown import command: {args.import_command}") + return 2 if args.work_command == "list": return work_cmd.list_sessions(target=args.target, limit=args.limit) if args.work_command == "latest": diff --git a/src/brigade/work_cmd.py b/src/brigade/work_cmd.py index 1de7ded6..b9964a5d 100644 --- a/src/brigade/work_cmd.py +++ b/src/brigade/work_cmd.py @@ -19,6 +19,7 @@ OK = "ok" WARN = "warn" FAIL = "fail" +IMPORT_KINDS = ("task", "finding", "decision", "preference", "incident", "link", "command") def _git(target: Path, *args: str) -> subprocess.CompletedProcess[str]: @@ -67,6 +68,10 @@ def _tasks_path(target: Path) -> Path: return _work_root(target) / "tasks.json" +def _imports_path(target: Path) -> Path: + return _work_root(target) / "imports" / "inbox.jsonl" + + def _git_snapshot(target: Path) -> dict[str, Any]: repo_root = _git_value(target, "rev-parse", "--show-toplevel") if repo_root is None: @@ -154,10 +159,42 @@ def _write_task_ledger(target: Path, payload: dict[str, Any]) -> None: _write_json(_tasks_path(target), payload) +def _read_imports(target: Path) -> list[dict[str, Any]]: + path = _imports_path(target) + if not path.exists(): + return [] + imports: list[dict[str, Any]] = [] + try: + lines = path.read_text().splitlines() + except OSError: + return [] + for line in lines: + if not line.strip(): + continue + try: + item = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(item, dict): + imports.append(item) + return imports + + +def _write_imports(target: Path, imports: list[dict[str, Any]]) -> None: + path = _imports_path(target) + path.parent.mkdir(parents=True, exist_ok=True) + rendered = "".join(json.dumps(item, sort_keys=True) + "\n" for item in imports) + path.write_text(rendered) + + def _task_sort_key(task: dict[str, Any]) -> str: return str(task.get("created_at") or task.get("id") or "") +def _import_sort_key(item: dict[str, Any]) -> str: + return str(item.get("created_at") or item.get("id") or "") + + def _task_text_key(text: str) -> str: return " ".join(text.casefold().split()) @@ -176,6 +213,19 @@ def _pending_tasks(target: Path) -> list[dict[str, Any]]: return tasks +def _pending_imports(target: Path) -> list[dict[str, Any]]: + imports = [ + item + for item in _read_imports(target) + if isinstance(item, dict) + and item.get("status", "pending") == "pending" + and isinstance(item.get("text"), str) + and item["text"].strip() + ] + imports.sort(key=_import_sort_key) + return imports + + def _find_pending_task_by_text(target: Path, text: str) -> dict[str, Any] | None: wanted = _task_text_key(text) if not wanted: @@ -186,6 +236,21 @@ def _find_pending_task_by_text(target: Path, text: str) -> dict[str, Any] | None return None +def _find_import(target: Path, import_id: str) -> tuple[dict[str, Any] | None, list[dict[str, Any]]]: + imports = _read_imports(target) + matches: list[dict[str, Any]] = [] + for item in imports: + if not isinstance(item, dict): + continue + if item.get("id") == import_id: + return item, imports + if isinstance(item.get("id"), str) and item["id"].startswith(import_id): + matches.append(item) + if len(matches) == 1: + return matches[0], imports + return None, imports + + 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]] = [] @@ -217,6 +282,42 @@ def _make_task(text: str, *, source: str = "manual", metadata: dict[str, Any] | return task +def _parse_metadata(items: list[str] | None) -> dict[str, str]: + metadata: dict[str, str] = {} + for item in items or []: + if "=" not in item: + raise ValueError("--metadata entries must use key=value") + key, value = item.split("=", 1) + key = key.strip() + if not key: + raise ValueError("--metadata entries must have a key") + metadata[key] = value.strip() + return metadata + + +def _make_import( + text: str, + *, + kind: str, + source: str, + metadata: dict[str, str] | None = None, +) -> dict[str, Any]: + now = _now() + created = now.isoformat() + item: dict[str, Any] = { + "id": f"{now.strftime('%Y%m%d-%H%M%S')}-{kind}-{_slug(text)}-{uuid4().hex[:6]}", + "kind": kind, + "source": source, + "text": text, + "status": "pending", + "created_at": created, + "updated_at": created, + } + if metadata: + item["metadata"] = metadata + return item + + def _add_task( target: Path, text: str, @@ -674,6 +775,7 @@ def _brief_payload(target: Path, *, limit: int = 3) -> dict[str, Any]: git = _git_snapshot(target) suggested = _suggested_command(active, resolved["task"], resolved["source"]) pending = _pending_tasks(target) + pending_imports = _pending_imports(target) return { "target": str(target), "git": git, @@ -683,6 +785,8 @@ def _brief_payload(target: Path, *, limit: int = 3) -> dict[str, Any]: "skipped_sessions": skipped, "tasks_path": str(_tasks_path(target)), "pending_tasks": pending, + "imports_path": str(_imports_path(target)), + "pending_imports": pending_imports, "dogfood": resolved["dogfood"], "next_source": resolved["source"], "task_id": resolved.get("task_id"), @@ -1126,6 +1230,18 @@ def brief(*, target: Path, limit: int = 3, json_output: bool = False) -> int: if len(pending) > 5: print(f" ... {len(pending) - 5} more") + pending_imports = payload["pending_imports"] + if isinstance(pending_imports, list) and pending_imports: + print("pending_imports:") + for item in pending_imports[:5]: + if not isinstance(item, dict): + continue + source = item.get("source") or "unknown" + kind = item.get("kind") or "task" + print(f" - {item.get('id')} [{kind}] {source}: {_short(str(item.get('text', '')))}") + if len(pending_imports) > 5: + print(f" ... {len(pending_imports) - 5} more") + recent = payload["recent_sessions"] if isinstance(recent, list) and recent: print("recent_sessions:") @@ -1248,6 +1364,149 @@ def task_done(*, target: Path, task_id: str) -> int: return 0 +def import_add( + *, + target: Path, + text: str, + kind: str = "task", + source: str = "manual", + metadata: 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) + return 2 + rendered = text.strip() + if not rendered: + print("error: import text is required", file=sys.stderr) + return 2 + if kind not in IMPORT_KINDS: + print(f"error: --kind must be one of: {', '.join(IMPORT_KINDS)}", file=sys.stderr) + return 2 + source_text = source.strip() or "manual" + try: + parsed_metadata = _parse_metadata(metadata) + except ValueError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + + imports = _read_imports(target) + item = _make_import(rendered, kind=kind, source=source_text, metadata=parsed_metadata) + imports.append(item) + _write_imports(target, imports) + print(f"import: {item['id']}") + print(f"status: {item['status']}") + print(f"kind: {item['kind']}") + print(f"source: {item['source']}") + print(f"text: {item['text']}") + return 0 + + +def import_list(*, target: Path, all_imports: bool = False, json_output: bool = False, limit: int = 20) -> 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 + imports = [item for item in _read_imports(target) if isinstance(item, dict)] + imports.sort(key=_import_sort_key) + if not all_imports: + imports = [item for item in imports if item.get("status", "pending") == "pending"] + imports = imports[:limit] + + if json_output: + print(json.dumps({"imports_path": str(_imports_path(target)), "imports": imports}, indent=2, sort_keys=True)) + return 0 + + print(f"work imports: {target}") + print(f"imports_path: {_imports_path(target)}") + if not imports: + print("imports: none") + return 0 + for item in imports: + status_text = item.get("status", "pending") + kind = item.get("kind", "task") + source = item.get("source", "manual") + print(f"- {item.get('id')} [{status_text}] {kind} from {source}: {_short(str(item.get('text', '')))}") + metadata = item.get("metadata") if isinstance(item.get("metadata"), dict) else {} + if metadata: + rendered = ", ".join(f"{key}={metadata[key]}" for key in sorted(metadata)) + print(f" metadata: {rendered}") + if item.get("task_id"): + print(f" task: {item['task_id']}") + return 0 + + +def import_show(*, target: Path, import_id: str) -> int: + target = target.expanduser().resolve() + if not target.is_dir(): + print(f"error: --target is not a directory: {target}", file=sys.stderr) + return 2 + item, _ = _find_import(target, import_id) + if item is None: + print(f"error: import not found: {import_id}", file=sys.stderr) + return 1 + print(f"import: {item.get('id')}") + print(f"status: {item.get('status', 'pending')}") + print(f"kind: {item.get('kind', '')}") + print(f"source: {item.get('source', '')}") + print(f"created_at: {item.get('created_at', '')}") + print(f"updated_at: {item.get('updated_at', '')}") + metadata = item.get("metadata") if isinstance(item.get("metadata"), dict) else {} + if metadata: + print("metadata:") + for key in sorted(metadata): + print(f" {key}: {metadata[key]}") + if item.get("promoted_at"): + print(f"promoted_at: {item['promoted_at']}") + if item.get("task_id"): + print(f"task: {item['task_id']}") + print(f"text: {item.get('text', '')}") + return 0 + + +def import_promote(*, target: Path, import_id: str) -> int: + target = target.expanduser().resolve() + if not target.is_dir(): + print(f"error: --target is not a directory: {target}", file=sys.stderr) + return 2 + item, imports = _find_import(target, import_id) + if item is None: + print(f"error: import not found: {import_id}", file=sys.stderr) + return 1 + text = str(item.get("text") or "").strip() + 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"] + _write_imports(target, imports) + print(f"import: {item.get('id')}") + print(f"status: {item.get('status')}") + print(f"task: {task['id']}") + print(f"created: {created}") + print(f"text: {task['text']}") + 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 caa3417b..60f50955 100644 --- a/tests/test_work_cmd.py +++ b/tests/test_work_cmd.py @@ -561,6 +561,120 @@ def test_work_brief_includes_pending_tasks(tmp_path, monkeypatch, capsys): assert payload["suggested_command"] == "brigade work run" +def test_work_import_add_list_show_and_promote(tmp_path, monkeypatch, capsys): + _init_git_repo(tmp_path) + times = iter( + [ + datetime(2026, 5, 26, 12, 0, 0, tzinfo=timezone.utc), + datetime(2026, 5, 26, 12, 30, 0, tzinfo=timezone.utc), + datetime(2026, 5, 26, 12, 30, 1, tzinfo=timezone.utc), + ] + ) + monkeypatch.setattr(work_cmd, "_now", lambda: next(times)) + + assert ( + work_cmd.import_add( + target=tmp_path, + text="Refresh the stale memory card", + kind="task", + source="slack", + metadata=["channel=eng", "thread=abc123"], + ) + == 0 + ) + out = capsys.readouterr().out + assert "import:" in out + assert "kind: task" in out + assert "source: slack" in out + import_id = out.split("import: ", 1)[1].splitlines()[0] + + assert work_cmd.import_list(target=tmp_path) == 0 + out = capsys.readouterr().out + assert "work imports:" in out + assert import_id in out + assert "[pending] task from slack: Refresh the stale memory card" in out + + assert work_cmd.import_show(target=tmp_path, import_id=import_id[:12]) == 0 + out = capsys.readouterr().out + assert f"import: {import_id}" in out + assert "status: pending" in out + assert "channel: eng" in out + assert "thread: abc123" in out + + assert work_cmd.import_promote(target=tmp_path, import_id=import_id[:12]) == 0 + out = capsys.readouterr().out + assert "status: promoted" in out + assert "created: True" in out + task_id = out.split("task: ", 1)[1].splitlines()[0] + + assert work_cmd.tasks(target=tmp_path, json_output=True) == 0 + payload = json.loads(capsys.readouterr().out) + task = payload["tasks"][0] + assert task["id"] == task_id + assert task["text"] == "Refresh the stale memory card" + assert task["source"] == "import:slack" + assert task["metadata"]["import_id"] == import_id + assert task["metadata"]["import_kind"] == "task" + assert task["metadata"]["import_source"] == "slack" + assert task["metadata"]["channel"] == "eng" + + 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 + imports_payload = json.loads(capsys.readouterr().out) + assert imports_payload["imports"][0]["status"] == "promoted" + assert imports_payload["imports"][0]["task_id"] == task_id + + +def test_work_import_promote_reuses_existing_pending_task(tmp_path, monkeypatch, capsys): + _init_git_repo(tmp_path) + times = iter( + [ + datetime(2026, 5, 26, 12, 0, 0, tzinfo=timezone.utc), + datetime(2026, 5, 26, 12, 1, 0, tzinfo=timezone.utc), + datetime(2026, 5, 26, 12, 2, 0, tzinfo=timezone.utc), + ] + ) + monkeypatch.setattr(work_cmd, "_now", lambda: next(times)) + assert work_cmd.task_add(target=tmp_path, text="Refresh stale card") == 0 + task_id = capsys.readouterr().out.split("task: ", 1)[1].splitlines()[0] + assert work_cmd.import_add(target=tmp_path, text=" refresh stale card ", source="memory-care") == 0 + import_id = capsys.readouterr().out.split("import: ", 1)[1].splitlines()[0] + + assert work_cmd.import_promote(target=tmp_path, import_id=import_id) == 0 + out = capsys.readouterr().out + assert f"task: {task_id}" in out + assert "created: False" in out + ledger = json.loads((tmp_path / ".brigade" / "work" / "tasks.json").read_text()) + assert len(ledger["tasks"]) == 1 + + +def test_work_brief_includes_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="Review expired decision card", + kind="finding", + source="memory-care", + ) + == 0 + ) + capsys.readouterr() + + assert work_cmd.brief(target=tmp_path, json_output=True) == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["imports_path"].endswith(".brigade/work/imports/inbox.jsonl") + 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" + + 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) @@ -1032,6 +1146,70 @@ def fake_task_done(**kwargs): ] +def test_work_import_cli(tmp_path, monkeypatch): + seen = [] + + def fake_import_add(**kwargs): + seen.append(("add", kwargs)) + return 0 + + def fake_import_list(**kwargs): + seen.append(("list", kwargs)) + return 0 + + def fake_import_show(**kwargs): + seen.append(("show", kwargs)) + return 0 + + def fake_import_promote(**kwargs): + seen.append(("promote", 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_show", fake_import_show) + monkeypatch.setattr(work_cmd, "import_promote", fake_import_promote) + + assert ( + cli.main( + [ + "work", + "import", + "add", + "refresh", + "card", + "--target", + str(tmp_path), + "--kind", + "finding", + "--source", + "discord", + "--metadata", + "channel=dev", + ] + ) + == 0 + ) + assert cli.main(["work", "import", "list", "--target", str(tmp_path), "--all", "--json", "--limit", "3"]) == 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 seen == [ + ( + "add", + { + "target": tmp_path, + "text": "refresh card", + "kind": "finding", + "source": "discord", + "metadata": ["channel=dev"], + }, + ), + ("list", {"target": tmp_path, "all_imports": True, "json_output": True, "limit": 3}), + ("show", {"target": tmp_path, "import_id": "imp123"}), + ("promote", {"target": tmp_path, "import_id": "imp123"}), + ] + + def test_work_bootstrap_cli(tmp_path, monkeypatch): seen = {}