diff --git a/CHANGELOG.md b/CHANGELOG.md index 8436cee2..dc7c2f91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,9 +46,12 @@ 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 validate` and `brigade work import ingest` for scanner-authored JSONL import files. +- `brigade work import memory-care` to convert `memory/cards/decay/refresh-queue.json` into local work imports. - `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. +- `docs/import-schema.md` documenting the local import JSONL contract for scanners and wrappers. - `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. diff --git a/README.md b/README.md index 24de8f50..db24f42f 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,9 @@ 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 validate imports.jsonl +brigade work import ingest imports.jsonl +brigade work import memory-care brigade work import triage brigade work import promote brigade work import promote --all --source memory-care --kind task @@ -186,7 +189,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 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`. +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. Scanner authors can write JSONL records documented in [`docs/import-schema.md`](docs/import-schema.md), then run `brigade work import validate imports.jsonl` and `brigade work import ingest imports.jsonl`. Use `brigade work import memory-care` to convert `memory/cards/decay/refresh-queue.json` into local imports. 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 729059b2..f6be46ba 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -16,13 +16,16 @@ Status: in progress. ## Next Phase: Scanner-Ready Inbox -Status: in progress. +Status: active. 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. +- Document the scanner JSONL contract so external producers can target Brigade without importing Brigade internals. +- Validate and ingest scanner-authored JSONL files. - Let wrappers import candidate tasks, findings, decisions, preferences, incidents, links, and commands without knowing Brigade internals. +- Convert memory-care refresh queues into local task imports. - Promote selected imports into the work task ledger, with source metadata preserved. - Dismiss noisy imports so scanners can be useful without leaving permanent queue clutter. - Batch-promote reviewed imports by source and kind. diff --git a/docs/import-schema.md b/docs/import-schema.md new file mode 100644 index 00000000..20e2cbb9 --- /dev/null +++ b/docs/import-schema.md @@ -0,0 +1,81 @@ +# Brigade Work Import JSONL Schema + +Brigade work imports are the local handoff contract for scanners, wrappers, and chat adapters that discover candidate work. Producers write JSON Lines files, then an operator or wrapper validates and ingests them with `brigade work import`. + +Imports are local review items. They do not write canonical memory directly. + +## Commands + +```bash +brigade work import validate imports.jsonl +brigade work import ingest imports.jsonl +brigade work import memory-care +brigade work import triage +brigade work import promote --all --source memory-care --kind task +``` + +`validate` checks a JSONL file without writing. `ingest` appends valid records into `.brigade/work/imports/inbox.jsonl`, skipping duplicate pending records with the same source, kind, and normalized text. `memory-care` reads `memory/cards/decay/refresh-queue.json` and converts queued cards into task imports. + +## Record Shape + +Each non-empty line must be one JSON object: + +```json +{"text":"Refresh memory card memory/cards/tools.md: source-of-truth changed","kind":"task","source":"memory-care","metadata":{"card_file":"memory/cards/tools.md","reason":"source-of-truth changed"}} +``` + +Required fields: + +- `text`: non-empty string. The operator-facing candidate work. + +Optional fields: + +- `kind`: one of `task`, `finding`, `decision`, `preference`, `incident`, `link`, or `command`. Defaults to `task`. +- `source`: non-empty string identifying the producer, such as `memory-care`, `slack`, `discord`, `telegram`, or `repo-scan`. Defaults to `manual`. +- `metadata`: JSON object with producer-specific context. + +Recommended metadata keys: + +- `card_file`: memory card path for memory-care records. +- `reason`: short reason the item was produced. +- `workspace`: chat workspace or source workspace. +- `channel`: chat channel or surface name. +- `thread`: thread id, message range, or export locator. +- `confidence`: producer confidence such as `low`, `medium`, or `high`. +- `evidence`: local evidence path or compact summary, not raw private chat text. + +## Privacy Rules + +- Keep raw chat exports, message bodies, and third-party personal details out of public docs and public repos. +- Store source locators and summaries in metadata instead of raw message quotes. +- Route durable memory changes through reviewed Memory Handoffs. Do not let scanners edit memory cards directly by default. + +## Memory-Care Producer + +The memory-care producer reads this refresh queue by default: + +```text +memory/cards/decay/refresh-queue.json +``` + +Minimal queue shape: + +```json +{ + "cards": [ + { + "file": "memory/cards/example.md", + "reason": "source-of-truth changed" + } + ] +} +``` + +Run: + +```bash +brigade work import memory-care +brigade work import triage +``` + +The producer writes `task` imports with source `memory-care`, preserving `card_file`, `reason`, and `queue_path` metadata. diff --git a/src/brigade/cli.py b/src/brigade/cli.py index 3cf9f525..f35ae49f 100644 --- a/src/brigade/cli.py +++ b/src/brigade/cli.py @@ -180,6 +180,24 @@ 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_validate = import_sub.add_parser("validate", help="Validate a work import JSONL file.") + p_work_import_validate.add_argument("input_path", type=Path, help="JSONL file to validate.") + p_work_import_validate.add_argument("--json", action="store_true", help="Print machine-readable JSON.") + p_work_import_ingest = import_sub.add_parser("ingest", help="Validate and append a work import JSONL file.") + p_work_import_ingest.add_argument("input_path", type=Path, help="JSONL file to ingest.") + p_work_import_ingest.add_argument("--target", "-t", type=Path, default=Path("."), help="Repo or workspace to update.") + p_work_import_ingest.add_argument("--dry-run", action="store_true", help="Validate and report without writing imports.") + p_work_import_ingest.add_argument("--json", action="store_true", help="Print machine-readable JSON.") + p_work_import_memory_care = import_sub.add_parser("memory-care", help="Import memory-care refresh queue entries.") + p_work_import_memory_care.add_argument("--target", "-t", type=Path, default=Path("."), help="Repo or workspace to update.") + p_work_import_memory_care.add_argument( + "--queue", + type=Path, + default=None, + help="Refresh queue JSON. Defaults to memory/cards/decay/refresh-queue.json under target.", + ) + p_work_import_memory_care.add_argument("--dry-run", action="store_true", help="Report without writing imports.") + p_work_import_memory_care.add_argument("--json", action="store_true", help="Print machine-readable JSON.") 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.") @@ -572,6 +590,22 @@ def main(argv=None) -> int: json_output=args.json, limit=args.limit, ) + if args.import_command == "validate": + return work_cmd.import_validate(input_path=args.input_path, json_output=args.json) + if args.import_command == "ingest": + return work_cmd.import_ingest( + target=args.target, + input_path=args.input_path, + dry_run=args.dry_run, + json_output=args.json, + ) + if args.import_command == "memory-care": + return work_cmd.import_memory_care( + target=args.target, + queue=args.queue, + dry_run=args.dry_run, + json_output=args.json, + ) 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": diff --git a/src/brigade/work_cmd.py b/src/brigade/work_cmd.py index 2e49f3c1..87254f60 100644 --- a/src/brigade/work_cmd.py +++ b/src/brigade/work_cmd.py @@ -199,6 +199,100 @@ def _task_text_key(text: str) -> str: return " ".join(text.casefold().split()) +def _import_record_key(item: dict[str, Any]) -> tuple[str, str, str]: + return ( + str(item.get("source") or "manual"), + str(item.get("kind") or "task"), + _task_text_key(str(item.get("text") or "")), + ) + + +def _validate_import_record(value: object, *, label: str) -> tuple[dict[str, Any] | None, list[str]]: + errors: list[str] = [] + if not isinstance(value, dict): + return None, [f"{label}: expected JSON object"] + + text = value.get("text") + if not isinstance(text, str) or not text.strip(): + errors.append(f"{label}: text must be a non-empty string") + kind = value.get("kind", "task") + if not isinstance(kind, str) or kind not in IMPORT_KINDS: + errors.append(f"{label}: kind must be one of: {', '.join(IMPORT_KINDS)}") + source = value.get("source", "manual") + if not isinstance(source, str) or not source.strip(): + errors.append(f"{label}: source must be a non-empty string") + metadata = value.get("metadata", {}) + if metadata is None: + metadata = {} + if not isinstance(metadata, dict): + errors.append(f"{label}: metadata must be an object when present") + + if errors: + return None, errors + return { + "text": text.strip(), + "kind": kind, + "source": source.strip(), + "metadata": metadata, + }, [] + + +def _load_import_jsonl(path: Path) -> tuple[list[dict[str, Any]], list[str]]: + records: list[dict[str, Any]] = [] + errors: list[str] = [] + try: + lines = path.read_text().splitlines() + except OSError as exc: + return records, [f"{path}: {exc}"] + for line_number, line in enumerate(lines, start=1): + if not line.strip(): + continue + label = f"line {line_number}" + try: + value = json.loads(line) + except json.JSONDecodeError as exc: + errors.append(f"{label}: invalid JSON: {exc.msg}") + continue + record, record_errors = _validate_import_record(value, label=label) + errors.extend(record_errors) + if record is not None: + records.append(record) + return records, errors + + +def _append_import_records( + target: Path, + records: list[dict[str, Any]], + *, + dry_run: bool = False, +) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + imports = _read_imports(target) + existing = { + _import_record_key(item) + for item in imports + if isinstance(item, dict) and item.get("status", "pending") == "pending" + } + imported: list[dict[str, Any]] = [] + skipped: list[dict[str, Any]] = [] + for record in records: + key = _import_record_key(record) + if key[2] and key in existing: + skipped.append(record) + continue + item = _make_import( + str(record["text"]), + kind=str(record["kind"]), + source=str(record["source"]), + metadata=record.get("metadata") if isinstance(record.get("metadata"), dict) else None, + ) + imported.append(item) + existing.add(key) + if imported and not dry_run: + imports.extend(imported) + _write_imports(target, imports) + return imported, skipped + + def _pending_tasks(target: Path) -> list[dict[str, Any]]: ledger = _read_task_ledger(target) tasks = [ @@ -352,7 +446,7 @@ def _make_import( *, kind: str, source: str, - metadata: dict[str, str] | None = None, + metadata: dict[str, Any] | None = None, ) -> dict[str, Any]: now = _now() created = now.isoformat() @@ -1506,6 +1600,165 @@ def import_list(*, target: Path, all_imports: bool = False, json_output: bool = return 0 +def import_validate(*, input_path: Path, json_output: bool = False) -> int: + path = input_path.expanduser().resolve() + if not path.is_file(): + print(f"error: import file not found: {path}", file=sys.stderr) + return 2 + records, errors = _load_import_jsonl(path) + payload = { + "path": str(path), + "valid": not errors, + "records": len(records), + "errors": errors, + } + if json_output: + print(json.dumps(payload, indent=2, sort_keys=True)) + return 0 if not errors else 1 + print(f"import file: {path}") + print(f"records: {len(records)}") + if errors: + print(f"errors: {len(errors)}") + for error in errors: + print(f"- {error}") + return 1 + print("status: valid") + return 0 + + +def import_ingest( + *, + target: Path, + input_path: Path, + dry_run: bool = False, + json_output: bool = False, +) -> int: + target = target.expanduser().resolve() + if not target.is_dir(): + print(f"error: --target is not a directory: {target}", file=sys.stderr) + return 2 + path = input_path.expanduser().resolve() + if not path.is_file(): + print(f"error: import file not found: {path}", file=sys.stderr) + return 2 + records, errors = _load_import_jsonl(path) + if errors: + if json_output: + print( + json.dumps( + { + "path": str(path), + "imports_path": str(_imports_path(target)), + "valid": False, + "errors": errors, + }, + indent=2, + sort_keys=True, + ) + ) + else: + print(f"error: import file is invalid: {path}", file=sys.stderr) + for error in errors: + print(f"- {error}", file=sys.stderr) + return 2 + + imported, skipped = _append_import_records(target, records, dry_run=dry_run) + payload = { + "path": str(path), + "imports_path": str(_imports_path(target)), + "dry_run": dry_run, + "imported": len(imported), + "skipped_duplicates": len(skipped), + "imports": imported, + } + if json_output: + print(json.dumps(payload, indent=2, sort_keys=True)) + return 0 + print(f"import file: {path}") + print(f"imports_path: {_imports_path(target)}") + print(f"dry_run: {dry_run}") + print(f"imported: {len(imported)}") + print(f"skipped_duplicates: {len(skipped)}") + for item in imported: + print(f"- {item.get('id')} [{item.get('kind')}] {item.get('source')}: {_short(str(item.get('text', '')))}") + return 0 + + +def import_memory_care( + *, + target: Path, + queue: Path | None = None, + dry_run: bool = False, + json_output: bool = False, +) -> int: + target = target.expanduser().resolve() + if not target.is_dir(): + print(f"error: --target is not a directory: {target}", file=sys.stderr) + return 2 + queue_path = queue.expanduser().resolve() if queue is not None else target / "memory" / "cards" / "decay" / "refresh-queue.json" + if not queue_path.is_file(): + print(f"error: memory-care refresh queue not found: {queue_path}", file=sys.stderr) + return 2 + try: + payload = json.loads(queue_path.read_text()) + except json.JSONDecodeError as exc: + print(f"error: invalid memory-care refresh queue JSON: {exc}", file=sys.stderr) + return 2 + if not isinstance(payload, dict): + print(f"error: memory-care refresh queue must be an object: {queue_path}", file=sys.stderr) + return 2 + cards = payload.get("cards", []) + if not isinstance(cards, list): + print(f"error: memory-care refresh queue `cards` must be a list: {queue_path}", file=sys.stderr) + return 2 + + records: list[dict[str, Any]] = [] + for index, card in enumerate(cards, start=1): + if not isinstance(card, dict): + print(f"error: memory-care card entry {index} must be an object", file=sys.stderr) + return 2 + card_file = card.get("file") + if not isinstance(card_file, str) or not card_file.strip(): + print(f"error: memory-care card entry {index} requires file", file=sys.stderr) + return 2 + reason = card.get("reason") + reason_text = reason.strip() if isinstance(reason, str) and reason.strip() else "stale memory card" + records.append( + { + "text": f"Refresh memory card {card_file.strip()}: {reason_text}", + "kind": "task", + "source": "memory-care", + "metadata": { + "card_file": card_file.strip(), + "reason": reason_text, + "queue_path": str(queue_path), + }, + } + ) + imported, skipped = _append_import_records(target, records, dry_run=dry_run) + output = { + "queue": str(queue_path), + "imports_path": str(_imports_path(target)), + "dry_run": dry_run, + "queued_cards": len(cards), + "imported": len(imported), + "skipped_duplicates": len(skipped), + "imports": imported, + } + if json_output: + print(json.dumps(output, indent=2, sort_keys=True)) + return 0 + print(f"memory-care queue: {queue_path}") + print(f"imports_path: {_imports_path(target)}") + print(f"dry_run: {dry_run}") + print(f"queued_cards: {len(cards)}") + print(f"imported: {len(imported)}") + print(f"skipped_duplicates: {len(skipped)}") + for item in imported: + print(f"- {item.get('id')} {_short(str(item.get('text', '')))}") + 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) diff --git a/tests/test_work_cmd.py b/tests/test_work_cmd.py index e150c9bb..538a1f4c 100644 --- a/tests/test_work_cmd.py +++ b/tests/test_work_cmd.py @@ -649,6 +649,107 @@ def test_work_import_promote_reuses_existing_pending_task(tmp_path, monkeypatch, assert len(ledger["tasks"]) == 1 +def test_work_import_validate_and_ingest_jsonl(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), + ) + import_file = tmp_path / "imports.jsonl" + import_file.write_text( + json.dumps( + { + "text": "Review imported scanner item", + "kind": "finding", + "source": "scanner", + "metadata": {"thread": "abc123"}, + } + ) + + "\n" + ) + + assert work_cmd.import_validate(input_path=import_file) == 0 + out = capsys.readouterr().out + assert "status: valid" in out + assert "records: 1" in out + + assert work_cmd.import_ingest(target=tmp_path, input_path=import_file) == 0 + out = capsys.readouterr().out + assert "imported: 1" in out + assert "skipped_duplicates: 0" in out + assert work_cmd.import_ingest(target=tmp_path, input_path=import_file) == 0 + out = capsys.readouterr().out + assert "imported: 0" in out + assert "skipped_duplicates: 1" in out + + assert work_cmd.import_list(target=tmp_path, json_output=True) == 0 + payload = json.loads(capsys.readouterr().out) + assert len(payload["imports"]) == 1 + assert payload["imports"][0]["kind"] == "finding" + assert payload["imports"][0]["source"] == "scanner" + assert payload["imports"][0]["metadata"]["thread"] == "abc123" + + +def test_work_import_validate_reports_schema_errors(tmp_path, capsys): + import_file = tmp_path / "bad-imports.jsonl" + import_file.write_text('{"kind":"nope","metadata":[]}\nnot-json\n') + + assert work_cmd.import_validate(input_path=import_file) == 1 + out = capsys.readouterr().out + assert "errors: 4" in out + assert "line 1: text must be a non-empty string" in out + assert "line 1: kind must be one of:" in out + assert "line 1: metadata must be an object when present" in out + + assert work_cmd.import_validate(input_path=import_file, json_output=True) == 1 + payload = json.loads(capsys.readouterr().out) + assert payload["valid"] is False + assert len(payload["errors"]) == 4 + + +def test_work_import_memory_care_reads_refresh_queue(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), + ) + queue = tmp_path / "memory" / "cards" / "decay" / "refresh-queue.json" + queue.parent.mkdir(parents=True) + queue.write_text( + json.dumps( + { + "cards": [ + { + "file": "memory/cards/tools.md", + "reason": "source-of-truth changed", + } + ] + } + ) + ) + + assert work_cmd.import_memory_care(target=tmp_path) == 0 + out = capsys.readouterr().out + assert f"memory-care queue: {queue}" in out + assert "queued_cards: 1" in out + assert "imported: 1" in out + assert work_cmd.import_memory_care(target=tmp_path) == 0 + out = capsys.readouterr().out + assert "imported: 0" in out + assert "skipped_duplicates: 1" in out + + assert work_cmd.import_list(target=tmp_path, json_output=True) == 0 + payload = json.loads(capsys.readouterr().out) + item = payload["imports"][0] + assert item["kind"] == "task" + assert item["source"] == "memory-care" + assert item["text"] == "Refresh memory card memory/cards/tools.md: source-of-truth changed" + assert item["metadata"]["card_file"] == "memory/cards/tools.md" + assert item["metadata"]["reason"] == "source-of-truth changed" + + def test_work_import_triage_groups_pending_imports(tmp_path, monkeypatch, capsys): _init_git_repo(tmp_path) monkeypatch.setattr( @@ -1254,6 +1355,18 @@ def fake_import_list(**kwargs): seen.append(("list", kwargs)) return 0 + def fake_import_validate(**kwargs): + seen.append(("validate", kwargs)) + return 0 + + def fake_import_ingest(**kwargs): + seen.append(("ingest", kwargs)) + return 0 + + def fake_import_memory_care(**kwargs): + seen.append(("memory-care", kwargs)) + return 0 + def fake_import_triage(**kwargs): seen.append(("triage", kwargs)) return 0 @@ -1272,6 +1385,9 @@ def fake_import_dismiss(**kwargs): monkeypatch.setattr(work_cmd, "import_add", fake_import_add) monkeypatch.setattr(work_cmd, "import_list", fake_import_list) + monkeypatch.setattr(work_cmd, "import_validate", fake_import_validate) + monkeypatch.setattr(work_cmd, "import_ingest", fake_import_ingest) + monkeypatch.setattr(work_cmd, "import_memory_care", fake_import_memory_care) 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) @@ -1298,6 +1414,38 @@ def fake_import_dismiss(**kwargs): == 0 ) assert cli.main(["work", "import", "list", "--target", str(tmp_path), "--all", "--json", "--limit", "3"]) == 0 + assert cli.main(["work", "import", "validate", str(tmp_path / "imports.jsonl"), "--json"]) == 0 + assert ( + cli.main( + [ + "work", + "import", + "ingest", + str(tmp_path / "imports.jsonl"), + "--target", + str(tmp_path), + "--dry-run", + "--json", + ] + ) + == 0 + ) + assert ( + cli.main( + [ + "work", + "import", + "memory-care", + "--target", + str(tmp_path), + "--queue", + str(tmp_path / "refresh-queue.json"), + "--dry-run", + "--json", + ] + ) + == 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 ( @@ -1330,6 +1478,25 @@ def fake_import_dismiss(**kwargs): }, ), ("list", {"target": tmp_path, "all_imports": True, "json_output": True, "limit": 3}), + ("validate", {"input_path": tmp_path / "imports.jsonl", "json_output": True}), + ( + "ingest", + { + "target": tmp_path, + "input_path": tmp_path / "imports.jsonl", + "dry_run": True, + "json_output": True, + }, + ), + ( + "memory-care", + { + "target": tmp_path, + "queue": tmp_path / "refresh-queue.json", + "dry_run": True, + "json_output": True, + }, + ), ("triage", {"target": tmp_path, "json_output": True, "limit": 4}), ("show", {"target": tmp_path, "import_id": "imp123"}), (