"""CLI commands for Headroom Learn — offline failure learning.""" from __future__ import annotations from pathlib import Path from typing import TYPE_CHECKING, Any import click if TYPE_CHECKING: from ..learn.base import LearnPlugin from .main import main class _AgentChoice(click.ParamType): """Dynamic Click type that validates against the plugin registry.""" name = "agent" def get_metavar(self, param: click.Parameter, ctx: click.Context | None = None) -> str | None: return "[auto|]" def convert( self, value: str, param: click.Parameter | None, ctx: click.Context | None, ) -> str: if value == "auto": return value from ..learn.registry import get_registry reg = get_registry() if value.lower() not in reg: available = ", ".join(sorted(reg.keys())) self.fail(f"Unknown agent: {value}. Available: auto, {available}", param, ctx) return value.lower() def shell_complete( self, ctx: click.Context, param: click.Parameter, incomplete: str, ) -> list[click.shell_completion.CompletionItem]: from ..learn.registry import available_agent_names names = ["auto"] + available_agent_names() return [click.shell_completion.CompletionItem(n) for n in names if n.startswith(incomplete)] _AGENT_HELP = """Which coding agent to analyze. Auto-detects by default. \b Built-in: claude, codex, gemini. External plugins register via 'headroom.learn_plugin' entry point. Use 'auto' (default) to scan all detected agents.""" @main.command() @click.option( "--project", type=click.Path(exists=True, path_type=Path), default=None, help="Project directory to analyze. Defaults to current directory.", ) @click.option( "--all", "analyze_all", is_flag=True, default=False, help="Analyze all discovered projects.", ) @click.option( "--apply", is_flag=True, default=False, help="Write recommendations to context/memory files (default: dry-run).", ) @click.option( "--target", type=str, default=None, help="Override the context file learnings are written to (Claude Code only). " "Path is relative to the project root, or absolute. Defaults to CLAUDE.local.md " "(personal, gitignored). Pass CLAUDE.md to write to the team-shared file instead.", ) @click.option( "--agent", type=_AgentChoice(), default="auto", help=_AGENT_HELP, ) @click.option( "--model", type=str, default=None, help="LLM model for analysis (e.g., claude-sonnet-4-6, gpt-4o, gemini/gemini-flash-latest). " "Auto-detected from API keys if not specified.", ) @click.option( "--workers", "-j", type=click.IntRange(min=1), default=None, help="Parallel workers for session scanning. " "Default: auto (min of CPU count, 8). Use 1 for serial.", ) @click.option( "--main-only", is_flag=True, default=False, help="Only scan top-level main sessions, skipping nested subagent/workflow " "transcripts (Claude Code). Default scans everything.", ) @click.option( "--verbosity", "verbosity_mode", is_flag=True, default=False, help="Learn the user's preferred OUTPUT verbosity from behavioral signals " "(interrupts, fast-skips) instead of analyzing failures. Writes the level " "the output shaper applies, and seeds the savings baseline. --apply persists.", ) @click.option( "--llm-judge", is_flag=True, default=False, help="With --verbosity: let an LLM override the heuristic level (needs an API key).", ) def learn( project: Path | None, analyze_all: bool, apply: bool, target: str | None, agent: str, model: str | None, workers: int | None, main_only: bool, verbosity_mode: bool, llm_judge: bool, ) -> None: """Learn from past tool call failures to prevent future ones. Analyzes conversation history using an LLM to find failure patterns (wrong paths, missing modules, stubborn retries) and generates context that prevents them from recurring. Supports multiple coding agents via a plugin architecture. Built-in support for Claude Code, Codex, and Gemini CLI. External plugins can be installed via pip (entry point: headroom.learn_plugin). \b Examples: headroom learn # Auto-detect agent & model headroom learn --apply # Write recommendations headroom learn --model gpt-4o # Use GPT-4o for analysis headroom learn --all # Analyze all projects headroom learn --agent codex --all # Analyze all Codex sessions headroom learn --target CLAUDE.md # Write to the team-shared file """ import os from ..learn.analyzer import SessionAnalyzer, _detect_default_model from ..learn.registry import auto_detect_plugins, get_plugin # Flag-combination validation — reject contradictory/no-op combinations up # front rather than letting one flag silently win or be ignored. if analyze_all and project is not None: raise click.UsageError("--all and --project are mutually exclusive.") if llm_judge and not verbosity_mode: raise click.UsageError("--llm-judge only applies with --verbosity.") max_workers = workers if workers is not None else min(os.cpu_count() or 4, 8) # Verbosity learning is a distinct flow: it mines behavioral signals (no # failure analysis) and needs no LLM unless --llm-judge is set. if verbosity_mode: ignored = [ flag for flag, is_set in ( ("--target", target is not None), ("--main-only", main_only), ("--workers", workers is not None), ("--model", model is not None and not llm_judge), ) if is_set ] if ignored: verb = "is" if len(ignored) == 1 else "are" click.echo(f"Note: {', '.join(ignored)} {verb} ignored with --verbosity.") _run_verbosity( project=project, analyze_all=analyze_all, apply=apply, agent=agent, llm_judge=llm_judge, model=model, ) return # Resolve model early to fail fast with a clear message try: resolved_model = model or _detect_default_model() except RuntimeError as e: click.echo(f"Error: {e}") raise SystemExit(1) from None analyzer = SessionAnalyzer(model=resolved_model) # Determine which agents to scan agent_configs: list[tuple[str, LearnPlugin]] = [] if agent == "auto": detected = auto_detect_plugins() if not detected: click.echo("No coding agent data found.") return click.echo(f"Detected agents: {', '.join(p.display_name for p in detected)}") agent_configs = [(p.name, p) for p in detected] else: selected = get_plugin(agent) agent_configs = [(selected.name, selected)] total_projects = 0 total_failures = 0 total_recommendations = 0 matched_projects = 0 available_projects: list[tuple[str, Path]] = [] for agent_name, plugin in agent_configs: writer = plugin.create_writer() if target is not None: if hasattr(writer, "set_context_target"): writer.set_context_target(target) else: click.echo(f"Note: --target is not supported for {agent_name}; ignoring.") all_projects = plugin.discover_projects() if not all_projects: # An explicitly-selected agent with no data should say so rather than # exiting silently (the auto path aggregates across agents instead). if agent != "auto": click.echo(f"No {plugin.display_name} project data found.") continue available_projects.extend((agent_name, proj.project_path) for proj in all_projects) # Filter to target project(s) if analyze_all: targets = all_projects elif project: resolved = project.resolve() targets = [p for p in all_projects if p.project_path == resolved] if not targets: continue else: cwd = Path.cwd().resolve() targets = [p for p in all_projects if p.project_path == cwd] if not targets: for parent in cwd.parents: targets = [p for p in all_projects if p.project_path == parent] if targets: break if not targets and len(agent_configs) == 1: click.echo(f"No {agent_name} project data found for {cwd}") click.echo("Try: headroom learn --all or headroom learn --project ") click.echo(f"\nAvailable {agent_name} projects:") for proj_info in all_projects[:10]: click.echo(f" {proj_info.name:30s} {proj_info.project_path}") return for proj in targets: matched_projects += 1 click.echo(f"\n{'=' * 60}") click.echo(f"[{agent_name}] {proj.name}") click.echo(f"Path: {proj.project_path}") click.echo(f"{'=' * 60}") try: sessions = plugin.scan_project( proj, max_workers=max_workers, include_subagents=not main_only ) except Exception as exc: # One unreadable agent/project must not abort the whole # cross-agent run; skip it with a warning and continue. click.echo(f" Skipping (could not scan sessions): {exc}") continue if not sessions: click.echo(" No conversation data found.") continue click.echo(f" Analyzing with {resolved_model}...") result_data = analyzer.analyze(proj, sessions) total_projects += 1 total_failures += result_data.total_failures click.echo( f"\n Sessions: {result_data.total_sessions} | " f"Calls: {result_data.total_calls} | " f"Failures: {result_data.total_failures} ({result_data.failure_rate:.1%})" ) if result_data.failure_rate == 0 and not result_data.recommendations: click.echo(" No failures or patterns found.") continue recommendations = result_data.recommendations if not recommendations: click.echo(" No actionable patterns found.") continue total_recommendations += len(recommendations) click.echo(f" Recommendations: {len(recommendations)}") try: result = writer.write(recommendations, proj, dry_run=not apply) except OSError as e: click.echo( f" Warning: failed to write recommendations for {proj.project_path}: {e}" ) continue for warning in getattr(result, "warnings", None) or []: click.echo(f"\n ⚠ {warning}") for file_path, content in result.content_by_file.items(): click.echo(f"\n {'[WOULD WRITE]' if result.dry_run else '[WROTE]'} {file_path}") click.echo(f" {'─' * 50}") for line in content.split("\n"): if line.startswith("