feat(devpulse): compass v2 P2 — drone @devpulse compass command (DPLAN-0212)
Thin command layer over the P1 storage core: add/query/stats/rate/archive/review via drone @devpulse compass. Ratings shown in query output ([GOOD]/[BAD]/...), --db flag for testing, auto-discovered (no devpulse.py change). 18 cmd tests, seedgo 29/29, no regressions. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
0df8fee947
commit
0d042dcb2f
@@ -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 <id> <rating>
|
||||
archive <id>
|
||||
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 <id> <rating> Re-rate a decision
|
||||
compass archive <id> 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 <id> <rating>``."""
|
||||
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 <id> <rating>")
|
||||
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"<id> 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 <id>``."""
|
||||
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 <id>")
|
||||
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"<id> 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 <id> <rating>' or retire with 'compass archive <id>'.[/dim]")
|
||||
return True
|
||||
Reference in New Issue
Block a user