diff --git a/src/aipass/devpulse/apps/modules/compass.py b/src/aipass/devpulse/apps/modules/compass.py new file mode 100644 index 00000000..96742d0c --- /dev/null +++ b/src/aipass/devpulse/apps/modules/compass.py @@ -0,0 +1,422 @@ +# =================== AIPass ==================== +# Name: compass.py +# Description: Compass Module — drone command for devpulse's rated decision store +# Version: 1.0.0 +# Created: 2026-06-16 +# Modified: 2026-06-16 +# ============================================= + +""" +Compass Module — command routing for devpulse's rated decision store. + +Compass is the truth-store of decisions: short, *rated* choices +(``good | bad | impressive | interesting``) that devpulse consults at a fork. +The rating IS the signal — repeat the good, avoid the bad. See DPLAN-0212. + +This module is the thin command layer (FPLAN P2). It parses args, calls the +``compass`` storage handler (FPLAN P1), and renders results to the console. +No business logic lives here — that's the handler's job. + +Subcommands: + add "context" "decision" --rating R [--note ..] [--tags a,b] [--source ..] + query "question" [--rating R] [--limit N] + stats + rate + archive + review + +Every subcommand accepts ``--db PATH`` (passed through as ``db_path=``) for +testing and power use; omitted, it uses the real store. + +Auto-discovered by devpulse.py via the handle_command() convention. +""" + +from typing import List, Optional + +from aipass.prax import logger +from aipass.cli.apps.modules import err_console, error, warning +from aipass.devpulse.apps.handlers import compass +from aipass.devpulse.apps.handlers.json import json_handler + +console = err_console + +_VALID_SUBCOMMANDS = ("add", "query", "stats", "rate", "archive", "review") + +# Console colour per rating — the rating is the signal, so make it pop. +_RATING_STYLE = { + "good": "bold green", + "bad": "bold red", + "impressive": "bold magenta", + "interesting": "bold yellow", +} + +HELP_TEXT = """\ +[bold cyan]compass[/bold cyan] — devpulse rated decision store + +[bold]Usage:[/bold] + compass add "context" "decision" --rating R [opts] Store a rated decision + compass query "question" [--rating R] [--limit N] Search (rating shown) + compass stats Counts by rating/status + compass rate Re-rate a decision + compass archive Archive a decision + compass review Surface one to review + compass --help Show this help + +[bold]Ratings:[/bold] good | bad | impressive | interesting +[bold]Sources:[/bold] devpulse | patrick + +[bold]Options (add):[/bold] + --rating R Required. One of the ratings above. + --note "..." Optional human observation. + --tags a,b,c Optional comma-separated tags. + --source S Optional. devpulse (default) or patrick. + +[bold]Options (all subcommands):[/bold] + --db PATH Use an alternate SQLite store (testing / power use). + +[bold]Examples:[/bold] + drone @devpulse compass add "auth fork" "chose JWT over sessions" --rating good + drone @devpulse compass query "auth" --rating good --limit 3 + drone @devpulse compass stats + drone @devpulse compass rate 4 bad + drone @devpulse compass archive 4 + drone @devpulse compass review + +See DPLAN-0212 (design) and the compass handler (apps/handlers/compass/). +""" + + +def print_introspection() -> None: + """Display module introspection info.""" + console.print() + console.print("[bold cyan]compass Module[/bold cyan]") + console.print("[dim]Devpulse rated decision store. The truth-store of choices —[/dim]") + console.print("[dim]each decision rated; the rating is the signal at a fork.[/dim]") + console.print() + console.print("[yellow]Subcommands:[/yellow] [cyan]add, query, stats, rate, archive, review[/cyan]") + console.print("[dim]Run 'compass --help' for full usage.[/dim]") + console.print() + + +def handle_command(command: str, args: List[str]) -> bool: + """Route compass subcommands to the storage handler. + + Auto-discovered by devpulse.py module loader. + + Args: + command: The primary command string. + args: Additional arguments after the command. + + Returns: + True if the command was handled, False otherwise. + """ + if command != "compass": + return False + + if not args: + print_introspection() + return True + + if args[0] in ("--help", "-h", "help"): + console.print(HELP_TEXT) + return True + + subcommand = args[0] + sub_args = args[1:] + + if subcommand not in _VALID_SUBCOMMANDS: + error(f"Unknown compass subcommand: {subcommand}", suggestion="Use 'compass --help' for usage") + return True + + logger.info("[compass] subcommand=%s args=%s", subcommand, sub_args) + json_handler.log_operation("compass_command", {"subcommand": subcommand}) + + if subcommand == "add": + return _handle_add(sub_args) + if subcommand == "query": + return _handle_query(sub_args) + if subcommand == "stats": + return _handle_stats(sub_args) + if subcommand == "rate": + return _handle_rate(sub_args) + if subcommand == "archive": + return _handle_archive(sub_args) + if subcommand == "review": + return _handle_review(sub_args) + + return True + + +# ============================================================================= +# ARG PARSING HELPERS +# ============================================================================= + + +def _extract_flag(args: List[str], flag: str) -> tuple[List[str], Optional[str]]: + """Pull a single ``--flag VALUE`` pair out of args. + + Returns the remaining args (flag + value removed) and the value (or None + if the flag was absent). Raises ValueError if the flag is given without a + following value — errors must fail loud, never silent. + """ + value: Optional[str] = None + remaining: List[str] = [] + i = 0 + while i < len(args): + if args[i] == flag: + if i + 1 >= len(args): + raise ValueError(f"{flag} requires a value") + value = args[i + 1] + i += 2 + continue + remaining.append(args[i]) + i += 1 + return remaining, value + + +def _extract_db_path(args: List[str]) -> tuple[List[str], Optional[str]]: + """Pull the optional ``--db PATH`` flag out of args.""" + return _extract_flag(args, "--db") + + +def _rating_tag(rating: str) -> str: + """Render a coloured ``[RATING]`` tag for query/review output.""" + style = _RATING_STYLE.get(rating, "bold white") + return f"[{style}]\\[{(rating or '?').upper()}][/{style}]" + + +# ============================================================================= +# SUBCOMMAND HANDLERS +# ============================================================================= + + +def _handle_add(sub_args: List[str]) -> bool: + """Parse and dispatch ``compass add "context" "decision" --rating R [opts]``.""" + try: + rest, db_path = _extract_db_path(sub_args) + rest, rating = _extract_flag(rest, "--rating") + rest, note = _extract_flag(rest, "--note") + rest, tags = _extract_flag(rest, "--tags") + rest, source = _extract_flag(rest, "--source") + except ValueError as exc: + logger.warning("[compass] add arg-parse error: %s", exc) + error(str(exc), suggestion="Use 'compass --help' for usage") + return True + + if len(rest) < 2: + error('Usage: compass add "context" "decision" --rating R [--note ..] [--tags a,b] [--source ..]') + return True + if rating is None: + error("compass add requires --rating", suggestion="One of: good | bad | impressive | interesting") + return True + + context = rest[0] + decision = rest[1] + + try: + new_id = compass.add_decision( + context, + decision, + rating, + note=note, + tags=tags, + source=source if source is not None else "devpulse", + db_path=db_path, + ) + except ValueError as exc: + logger.warning("[compass] add rejected: %s", exc) + error(str(exc)) + return True + + console.print(f"[green]Added decision[/green] {_rating_tag(rating)} [bold]#{new_id}[/bold]") + console.print(f" [cyan]context:[/cyan] {context}") + console.print(f" [cyan]decision:[/cyan] {decision}") + if note: + console.print(f" [cyan]note:[/cyan] {note}") + if tags: + console.print(f" [cyan]tags:[/cyan] {tags}") + return True + + +def _handle_query(sub_args: List[str]) -> bool: + """Parse and dispatch ``compass query "question" [--rating R] [--limit N]``.""" + try: + rest, db_path = _extract_db_path(sub_args) + rest, rating = _extract_flag(rest, "--rating") + rest, limit_raw = _extract_flag(rest, "--limit") + except ValueError as exc: + logger.warning("[compass] query arg-parse error: %s", exc) + error(str(exc), suggestion="Use 'compass --help' for usage") + return True + + if not rest: + error('Usage: compass query "question" [--rating R] [--limit N]') + return True + + query_text = rest[0] + + limit = 5 + if limit_raw is not None: + try: + limit = int(limit_raw) + except ValueError as exc: + logger.warning("[compass] query bad --limit %r: %s", limit_raw, exc) + error(f"--limit must be an integer, got {limit_raw!r}") + return True + + try: + results = compass.query_decisions(query_text, rating=rating, limit=limit, db_path=db_path) + except ValueError as exc: + logger.warning("[compass] query rejected: %s", exc) + error(str(exc)) + return True + + _render_query_results(query_text, rating, results) + return True + + +def _render_query_results(query_text: str, rating: Optional[str], results: List[dict]) -> None: + """Render query results — rating shown prominently, most relevant first.""" + filt = f" [dim](rating={rating})[/dim]" if rating else "" + console.print(f"[bold]Compass[/bold] — {len(results)} result(s) for [cyan]{query_text!r}[/cyan]{filt}") + + if not results: + console.print("[dim]No matching decisions.[/dim]") + return + + console.print() + for r in results: + tag = _rating_tag(r.get("rating", "?")) + console.print(f"{tag} [bold]#{r.get('id', '?')}[/bold] [dim]{r.get('created', '?')}[/dim]") + console.print(f" [cyan]context:[/cyan] {r.get('context', '')}") + console.print(f" [cyan]decision:[/cyan] {r.get('decision', '')}") + if r.get("note"): + console.print(f" [cyan]note:[/cyan] {r['note']}") + if r.get("tags"): + console.print(f" [cyan]tags:[/cyan] {r['tags']}") + meta = f"source={r.get('source', '?')} status={r.get('status', '?')} surfaced={r.get('times_surfaced', 0)}" + console.print(f" [dim]{meta}[/dim]") + console.print() + + +def _handle_stats(sub_args: List[str]) -> bool: + """Dispatch ``compass stats`` and render readable counts.""" + try: + rest, db_path = _extract_db_path(sub_args) + except ValueError as exc: + logger.warning("[compass] stats arg-parse error: %s", exc) + error(str(exc)) + return True + if rest: + error(f"compass stats takes no positional args, got: {' '.join(rest)}") + return True + + data = compass.stats(db_path=db_path) + + console.print("[bold]Compass Stats[/bold]") + console.print(f" Total decisions: [bold]{data.get('total', 0)}[/bold]") + console.print(" [yellow]By rating:[/yellow]") + for rating, count in (data.get("by_rating") or {}).items(): + console.print(f" {_rating_tag(rating)} {count}") + console.print(" [yellow]By status:[/yellow]") + for status, count in (data.get("by_status") or {}).items(): + console.print(f" [cyan]{status:<10}[/cyan] {count}") + return True + + +def _handle_rate(sub_args: List[str]) -> bool: + """Dispatch ``compass rate ``.""" + try: + rest, db_path = _extract_db_path(sub_args) + except ValueError as exc: + logger.warning("[compass] rate arg-parse error: %s", exc) + error(str(exc)) + return True + + if len(rest) < 2: + error("Usage: compass rate ") + return True + + try: + decision_id = int(rest[0]) + except ValueError as exc: + logger.warning("[compass] rate bad id %r: %s", rest[0], exc) + error(f" must be an integer, got {rest[0]!r}") + return True + + rating = rest[1] + try: + changed = compass.rate(decision_id, rating, db_path=db_path) + except ValueError as exc: + logger.warning("[compass] rate rejected: %s", exc) + error(str(exc)) + return True + + if changed: + console.print(f"[green]Re-rated[/green] [bold]#{decision_id}[/bold] -> {_rating_tag(rating)}") + else: + warning(f"No decision with id {decision_id} — nothing changed.") + return True + + +def _handle_archive(sub_args: List[str]) -> bool: + """Dispatch ``compass archive ``.""" + try: + rest, db_path = _extract_db_path(sub_args) + except ValueError as exc: + logger.warning("[compass] archive arg-parse error: %s", exc) + error(str(exc)) + return True + + if not rest: + error("Usage: compass archive ") + return True + + try: + decision_id = int(rest[0]) + except ValueError as exc: + logger.warning("[compass] archive bad id %r: %s", rest[0], exc) + error(f" must be an integer, got {rest[0]!r}") + return True + + changed = compass.archive(decision_id, db_path=db_path) + if changed: + console.print( + f"[green]Archived[/green] [bold]#{decision_id}[/bold] [dim](kept as avoid-list, not deleted)[/dim]" + ) + else: + warning(f"No decision with id {decision_id} — nothing changed.") + return True + + +def _handle_review(sub_args: List[str]) -> bool: + """Dispatch ``compass review`` — surface one active decision to review.""" + try: + rest, db_path = _extract_db_path(sub_args) + except ValueError as exc: + logger.warning("[compass] review arg-parse error: %s", exc) + error(str(exc)) + return True + if rest: + error(f"compass review takes no positional args, got: {' '.join(rest)}") + return True + + result = compass.review(db_path=db_path) + if result is None: + console.print("[dim]No active decisions to review.[/dim]") + return True + + tag = _rating_tag(result.get("rating", "?")) + console.print(f"[bold]Compass Review[/bold] {tag} [bold]#{result.get('id', '?')}[/bold]") + console.print(f" [cyan]context:[/cyan] {result.get('context', '')}") + console.print(f" [cyan]decision:[/cyan] {result.get('decision', '')}") + if result.get("note"): + console.print(f" [cyan]note:[/cyan] {result['note']}") + if result.get("tags"): + console.print(f" [cyan]tags:[/cyan] {result['tags']}") + console.print( + f" [dim]created={result.get('created', '?')} last_reviewed={result.get('last_reviewed', '?')} " + f"surfaced={result.get('times_surfaced', 0)}[/dim]" + ) + console.print("[dim]Tip: re-rate with 'compass rate ' or retire with 'compass archive '.[/dim]") + return True diff --git a/src/aipass/devpulse/tests/test_compass_command.py b/src/aipass/devpulse/tests/test_compass_command.py new file mode 100644 index 00000000..f11f1696 --- /dev/null +++ b/src/aipass/devpulse/tests/test_compass_command.py @@ -0,0 +1,277 @@ +# =================== AIPass ==================== +# Name: test_compass_command.py +# Description: Tests for the compass module command router (FPLAN P2) +# Version: 1.0.0 +# Created: 2026-06-16 +# Modified: 2026-06-16 +# ============================================= + +"""Tests for the compass command router (FPLAN-0212 P2). + +These exercise the thin command layer (``apps/modules/compass.py``) end to +end against a real temp SQLite store via the ``--db`` flag — the same path the +live ``drone @devpulse compass`` invocation takes. Everything goes through the +module entry point (``handle_command``): the round-trip (add -> query -> see +rating) is driven and asserted entirely via the command's own console output, +so the storage handler is never reached into directly. +""" + +import re +from pathlib import Path + +import pytest + +from aipass.devpulse.apps.modules import compass as compass_cmd + + +@pytest.fixture +def db(tmp_path: Path) -> str: + """A temp DB path string, passed through to the command via --db.""" + return str(tmp_path / "compass_cmd_test.db") + + +def _output(capsys) -> str: + """Combined stdout+stderr (err_console / error() route to stderr).""" + captured = capsys.readouterr() + return captured.out + captured.err + + +def _add(capsys, db, context, decision, rating, *extra) -> int: + """Drive an add through the command entry point; return the new id. + + The id is parsed from the command's own ``#`` confirmation line — we + never reach into the storage handler. + """ + capsys.readouterr() # isolate this add's output + compass_cmd.handle_command("compass", ["add", context, decision, "--rating", rating, "--db", db, *extra]) + out = _output(capsys) + match = re.search(r"#(\d+)", out) + assert match, f"add did not report a new id; output was: {out!r}" + return int(match.group(1)) + + +def _query_out(capsys, db, query, *extra) -> str: + """Run a query via the command and return its captured output (drained).""" + capsys.readouterr() # drop anything pending so we only see this query + compass_cmd.handle_command("compass", ["query", query, "--db", db, *extra]) + return _output(capsys) + + +def _stats_out(capsys, db) -> str: + """Run stats via the command and return its captured output (drained).""" + capsys.readouterr() + compass_cmd.handle_command("compass", ["stats", "--db", db]) + return _output(capsys) + + +# --------------------------------------------------------------------------- +# Routing basics +# --------------------------------------------------------------------------- + + +def test_rejects_unrelated_command(): + """Router returns False for commands that aren't 'compass'.""" + assert compass_cmd.handle_command("watchdog", []) is False + + +def test_no_args_shows_introspection(capsys): + """Bare 'compass' shows introspection that mentions compass + subcommands.""" + assert compass_cmd.handle_command("compass", []) is True + out = _output(capsys).lower() + assert "compass" in out + assert "add" in out and "query" in out + + +def test_help_flag_shows_usage(capsys): + """--help prints usage covering every subcommand.""" + assert compass_cmd.handle_command("compass", ["--help"]) is True + out = _output(capsys).lower() + assert "usage" in out + for sub in ("add", "query", "stats", "rate", "archive", "review"): + assert sub in out + + +def test_unknown_subcommand_errors(capsys): + """Unknown subcommand surfaces a clean error, still returns True.""" + assert compass_cmd.handle_command("compass", ["bogus"]) is True + out = _output(capsys).lower() + assert "bogus" in out or "unknown" in out + + +# --------------------------------------------------------------------------- +# add -> query round-trip (rating must be visible) +# --------------------------------------------------------------------------- + + +def test_add_then_query_shows_rating(capsys, db): + """add stores a decision; query surfaces it with the [GOOD] rating tag.""" + assert ( + compass_cmd.handle_command( + "compass", + ["add", "auth fork", "chose JWT over sessions", "--rating", "good", "--note", "worked", "--db", db], + ) + is True + ) + add_out = _output(capsys) + assert "GOOD" in add_out # rating shown on add too + + assert compass_cmd.handle_command("compass", ["query", "JWT", "--db", db]) is True + q_out = _output(capsys) + assert "GOOD" in q_out # the rating tag is the whole point + assert "chose JWT over sessions" in q_out + assert "auth fork" in q_out + + +def test_query_rating_filter(capsys, db): + """--rating filters query results to the matching rating only.""" + compass_cmd.handle_command("compass", ["add", "ctx good", "good choice here", "--rating", "good", "--db", db]) + compass_cmd.handle_command("compass", ["add", "ctx bad", "bad choice here", "--rating", "bad", "--db", db]) + capsys.readouterr() # drain add output + + assert compass_cmd.handle_command("compass", ["query", "choice", "--rating", "bad", "--db", db]) is True + out = _output(capsys) + assert "bad choice here" in out + assert "good choice here" not in out + + +def test_add_persists_to_store(capsys, db): + """add persists to the store; a later query surfaces it with its rating.""" + _add(capsys, db, "persist ctx", "persist decision", "interesting") + + out = _query_out(capsys, db, "persist") + assert "INTERESTING" in out + assert "persist decision" in out + assert "1 result(s)" in out + + +# --------------------------------------------------------------------------- +# stats +# --------------------------------------------------------------------------- + + +def test_stats_reports_counts(capsys, db): + """stats shows total plus by-rating / by-status breakdown.""" + compass_cmd.handle_command("compass", ["add", "c1", "d1", "--rating", "good", "--db", db]) + compass_cmd.handle_command("compass", ["add", "c2", "d2", "--rating", "bad", "--db", db]) + capsys.readouterr() + + assert compass_cmd.handle_command("compass", ["stats", "--db", db]) is True + out = _output(capsys).lower() + assert "total" in out + assert "2" in out + assert "good" in out and "bad" in out + assert "active" in out + + +# --------------------------------------------------------------------------- +# rate +# --------------------------------------------------------------------------- + + +def test_rate_changes_rating(capsys, db): + """rate re-rates an existing decision.""" + new_id = _add(capsys, db, "rate ctx", "rate decision", "good") + + assert compass_cmd.handle_command("compass", ["rate", str(new_id), "bad", "--db", db]) is True + out = _output(capsys) + assert "BAD" in out + + # Confirm the new rating sticks: querying the row now shows [BAD], not [GOOD]. + q_out = _query_out(capsys, db, "rate") + assert "BAD" in q_out + assert "GOOD" not in q_out + + +def test_rate_missing_id_warns(capsys, db): + """rate on a non-existent id reports 'nothing changed', does not crash.""" + assert compass_cmd.handle_command("compass", ["rate", "999", "good", "--db", db]) is True + out = _output(capsys).lower() + assert "999" in out and ("nothing changed" in out or "no decision" in out) + + +# --------------------------------------------------------------------------- +# archive +# --------------------------------------------------------------------------- + + +def test_archive_removes_from_query(capsys, db): + """archive flips status; archived rows drop out of query, stats reflect it.""" + new_id = _add(capsys, db, "arch ctx", "arch decision", "good") + + assert compass_cmd.handle_command("compass", ["archive", str(new_id), "--db", db]) is True + out = _output(capsys).lower() + assert "archived" in out + + # Archived rows no longer surface in query... + q_out = _query_out(capsys, db, "arch") + assert "0 result(s)" in q_out + assert "arch decision" not in q_out + # ...but stats still count them under archived. + s_out = _stats_out(capsys, db).lower() + assert "archived" in s_out + + +# --------------------------------------------------------------------------- +# review +# --------------------------------------------------------------------------- + + +def test_review_surfaces_a_decision(capsys, db): + """review surfaces an active decision with its rating shown.""" + _add(capsys, db, "review ctx", "review decision", "impressive") + + capsys.readouterr() + assert compass_cmd.handle_command("compass", ["review", "--db", db]) is True + out = _output(capsys) + assert "IMPRESSIVE" in out + assert "review decision" in out + + +def test_review_empty_store(capsys, db): + """review on an empty store reports nothing to review (no crash).""" + assert compass_cmd.handle_command("compass", ["review", "--db", db]) is True + out = _output(capsys).lower() + assert "no active" in out or "nothing" in out + + +# --------------------------------------------------------------------------- +# Error surfacing — must fail loud, never silent +# --------------------------------------------------------------------------- + + +def test_bad_rating_error_surfaces(capsys, db): + """add with an invalid rating surfaces the handler's ValueError message.""" + assert compass_cmd.handle_command("compass", ["add", "ctx", "decision", "--rating", "terrible", "--db", db]) is True + out = _output(capsys).lower() + assert "rating" in out and "terrible" in out + # Nothing should have been stored — stats reports total 0. + assert "total decisions: 0" in _stats_out(capsys, db).lower() + + +def test_add_requires_rating(capsys, db): + """add without --rating fails loud.""" + assert compass_cmd.handle_command("compass", ["add", "ctx", "decision", "--db", db]) is True + out = _output(capsys).lower() + assert "rating" in out + + +def test_add_missing_positionals(capsys, db): + """add with too few positional args shows usage, stores nothing.""" + assert compass_cmd.handle_command("compass", ["add", "only-context", "--rating", "good", "--db", db]) is True + out = _output(capsys).lower() + assert "usage" in out + assert "total decisions: 0" in _stats_out(capsys, db).lower() + + +def test_query_bad_limit_errors(capsys, db): + """query with a non-integer --limit fails loud.""" + assert compass_cmd.handle_command("compass", ["query", "anything", "--limit", "abc", "--db", db]) is True + out = _output(capsys).lower() + assert "limit" in out + + +def test_flag_without_value_errors(capsys, db): + """A flag given without a following value fails loud (no silent swallow).""" + assert compass_cmd.handle_command("compass", ["query", "x", "--rating"]) is True + out = _output(capsys).lower() + assert "rating" in out and "value" in out