headroom/tests/test_cli_learn.py

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

475 lines
18 KiB
Python
Raw Normal View History

from __future__ import annotations
2026-05-09 22:11:42 -07:00
import os
from pathlib import Path
from types import SimpleNamespace
import click
import click.shell_completion as click_shell_completion
import pytest
from click.testing import CliRunner
from headroom.cli.learn import _AgentChoice
from headroom.cli.main import main
@pytest.fixture
def runner() -> CliRunner:
return CliRunner()
class FakeWriter:
def __init__(self) -> None:
self.calls: list[tuple[list[object], object, bool]] = []
self.fail_for: object | None = None
def write(self, recommendations, project, dry_run: bool): # noqa: ANN001, ANN201
self.calls.append((recommendations, project, dry_run))
if project is self.fail_for:
raise PermissionError(f"cannot write {project.project_path}")
return SimpleNamespace(
dry_run=dry_run,
content_by_file={
Path(project.project_path) / "AGENTS.md": "<!-- headroom -->\nRule 1\nRule 2"
},
)
class FakePlugin:
def __init__(self, name: str, display_name: str, projects: list[object]) -> None:
self.name = name
self.display_name = display_name
self._projects = projects
self.writer = FakeWriter()
self.scan_calls: list[tuple[object, int]] = []
fix(learn): scan subagent and workflow transcripts (#1045) ## Description `headroom learn` only scanned top-level main Claude Code sessions (`<project>/<uuid>.jsonl`). Nested subagent and workflow transcripts under `<project>/<uuid>/subagents/**` were not opened, which hid a large amount of tool-call failure and token-spend activity from failure mining and downstream analysis. This change makes the Claude scanner descend into nested transcripts by default and tag each `SessionData` with its source. `--main-only` restores the previous top-level-only scan scope. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - Updated `ClaudeCodePlugin.scan_project` to discover nested subagent and workflow transcripts by default. - Added source tagging for `main`, `subagent`, and `workflow` sessions. - Added `--main-only` and `include_subagents` plumbing so callers can opt back into top-level-only scanning. - Added the `include_subagents` scanner parameter to Codex/Gemini as a documented no-op because those scanners use flat session layouts. - Added regression tests for nested discovery, source tagging, parallel scanning, and CLI flag threading. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality ### Test Output ```text Full learn + CLI suite: # 186 passed, 2 skipped GitHub Actions CI for this PR: # build, build-wheel, lint, tests, e2e, CodeQL, and native wrapper checks passed ``` ## Real Behavior Proof - Environment: local Claude Code corpus with nested subagent/workflow transcripts. - Exact command / steps: Scanned the corpus with the previous top-level-only behavior and then with nested transcript discovery enabled. - Observed result: The scanner saw 24 sessions before and 306 sessions after descending into nested transcripts. - Not tested: Codex/Gemini nested transcript discovery, because those providers currently use flat session layouts and treat `include_subagents` as a no-op. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review --------- Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-06-16 12:20:37 -07:00
self.last_include_subagents: bool | None = None
def detect(self) -> bool:
return True
def create_writer(self) -> FakeWriter:
return self.writer
def discover_projects(self) -> list[object]:
return self._projects
fix(learn): scan subagent and workflow transcripts (#1045) ## Description `headroom learn` only scanned top-level main Claude Code sessions (`<project>/<uuid>.jsonl`). Nested subagent and workflow transcripts under `<project>/<uuid>/subagents/**` were not opened, which hid a large amount of tool-call failure and token-spend activity from failure mining and downstream analysis. This change makes the Claude scanner descend into nested transcripts by default and tag each `SessionData` with its source. `--main-only` restores the previous top-level-only scan scope. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - Updated `ClaudeCodePlugin.scan_project` to discover nested subagent and workflow transcripts by default. - Added source tagging for `main`, `subagent`, and `workflow` sessions. - Added `--main-only` and `include_subagents` plumbing so callers can opt back into top-level-only scanning. - Added the `include_subagents` scanner parameter to Codex/Gemini as a documented no-op because those scanners use flat session layouts. - Added regression tests for nested discovery, source tagging, parallel scanning, and CLI flag threading. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality ### Test Output ```text Full learn + CLI suite: # 186 passed, 2 skipped GitHub Actions CI for this PR: # build, build-wheel, lint, tests, e2e, CodeQL, and native wrapper checks passed ``` ## Real Behavior Proof - Environment: local Claude Code corpus with nested subagent/workflow transcripts. - Exact command / steps: Scanned the corpus with the previous top-level-only behavior and then with nested transcript discovery enabled. - Observed result: The scanner saw 24 sessions before and 306 sessions after descending into nested transcripts. - Not tested: Codex/Gemini nested transcript discovery, because those providers currently use flat session layouts and treat `include_subagents` as a no-op. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review --------- Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-06-16 12:20:37 -07:00
def scan_project(self, project, max_workers: int = 1, include_subagents: bool = True): # noqa: ANN001, ANN201
self.scan_calls.append((project, max_workers))
fix(learn): scan subagent and workflow transcripts (#1045) ## Description `headroom learn` only scanned top-level main Claude Code sessions (`<project>/<uuid>.jsonl`). Nested subagent and workflow transcripts under `<project>/<uuid>/subagents/**` were not opened, which hid a large amount of tool-call failure and token-spend activity from failure mining and downstream analysis. This change makes the Claude scanner descend into nested transcripts by default and tag each `SessionData` with its source. `--main-only` restores the previous top-level-only scan scope. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - Updated `ClaudeCodePlugin.scan_project` to discover nested subagent and workflow transcripts by default. - Added source tagging for `main`, `subagent`, and `workflow` sessions. - Added `--main-only` and `include_subagents` plumbing so callers can opt back into top-level-only scanning. - Added the `include_subagents` scanner parameter to Codex/Gemini as a documented no-op because those scanners use flat session layouts. - Added regression tests for nested discovery, source tagging, parallel scanning, and CLI flag threading. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality ### Test Output ```text Full learn + CLI suite: # 186 passed, 2 skipped GitHub Actions CI for this PR: # build, build-wheel, lint, tests, e2e, CodeQL, and native wrapper checks passed ``` ## Real Behavior Proof - Environment: local Claude Code corpus with nested subagent/workflow transcripts. - Exact command / steps: Scanned the corpus with the previous top-level-only behavior and then with nested transcript discovery enabled. - Observed result: The scanner saw 24 sessions before and 306 sessions after descending into nested transcripts. - Not tested: Codex/Gemini nested transcript discovery, because those providers currently use flat session layouts and treat `include_subagents` as a no-op. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review --------- Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-06-16 12:20:37 -07:00
self.last_include_subagents = include_subagents
return [SimpleNamespace(events=["event"], tool_calls=[], failure_count=0)]
class FakeAnalyzer:
def __init__(self, model: str | None = None) -> None:
self.model = model
self.calls: list[tuple[object, list[object]]] = []
def analyze(self, project, sessions): # noqa: ANN001, ANN201
self.calls.append((project, sessions))
return SimpleNamespace(
total_sessions=len(sessions),
total_calls=3,
total_failures=1,
failure_rate=1 / 3,
recommendations=[SimpleNamespace(section="Rules")],
)
def test_agent_choice_convert_and_shell_complete(monkeypatch: pytest.MonkeyPatch) -> None:
choice = _AgentChoice()
monkeypatch.setattr(click, "shell_completion", click_shell_completion)
monkeypatch.setattr(
"headroom.learn.registry.get_registry",
lambda: {"codex": object(), "claude": object()},
)
monkeypatch.setattr(
"headroom.learn.registry.available_agent_names",
lambda: ["claude", "codex"],
)
assert choice.convert("auto", None, None) == "auto"
assert choice.convert("CODEX", None, None) == "codex"
with pytest.raises(Exception, match="Unknown agent: bad"):
choice.convert("bad", None, None)
completions = choice.shell_complete(None, None, "c") # type: ignore[arg-type]
assert [item.value for item in completions] == ["claude", "codex"]
assert choice.get_metavar(None) == "[auto|<agent>]" # type: ignore[arg-type]
def test_learn_exits_cleanly_when_model_detection_fails(
monkeypatch: pytest.MonkeyPatch, runner: CliRunner
) -> None:
monkeypatch.setattr(
"headroom.learn.analyzer._detect_default_model",
lambda: (_ for _ in ()).throw(RuntimeError("no model")),
)
result = runner.invoke(main, ["learn"], catch_exceptions=False)
assert result.exit_code == 1
assert "Error: no model" in result.output
def test_learn_auto_agent_reports_no_detected_plugins(
monkeypatch: pytest.MonkeyPatch, runner: CliRunner
) -> None:
monkeypatch.setattr("headroom.learn.analyzer._detect_default_model", lambda: "gpt-4o")
monkeypatch.setattr("headroom.learn.registry.auto_detect_plugins", lambda: [])
monkeypatch.setattr("headroom.learn.analyzer.SessionAnalyzer", FakeAnalyzer)
result = runner.invoke(main, ["learn"], catch_exceptions=False)
assert result.exit_code == 0
assert "No coding agent data found." in result.output
def test_learn_single_agent_shows_available_projects_when_cwd_missing(
monkeypatch: pytest.MonkeyPatch, runner: CliRunner, tmp_path: Path
) -> None:
project = SimpleNamespace(name="demo", project_path=tmp_path / "demo")
plugin = FakePlugin("codex", "Codex", [project])
monkeypatch.setattr("headroom.learn.analyzer._detect_default_model", lambda: "gpt-4o")
monkeypatch.setattr("headroom.learn.registry.get_plugin", lambda name: plugin)
monkeypatch.setattr("headroom.learn.analyzer.SessionAnalyzer", FakeAnalyzer)
with runner.isolated_filesystem(temp_dir=tmp_path):
result = runner.invoke(main, ["learn", "--agent", "codex"], catch_exceptions=False)
assert result.exit_code == 0
assert "No codex project data found for" in result.output
assert "Available codex projects:" in result.output
assert "demo" in result.output
def test_learn_project_lookup_and_apply_flow(
monkeypatch: pytest.MonkeyPatch, runner: CliRunner, tmp_path: Path
) -> None:
project_path = tmp_path / "project-a"
project_path.mkdir()
matched = SimpleNamespace(name="project-a", project_path=project_path)
unmatched = SimpleNamespace(name="project-b", project_path=tmp_path / "project-b")
plugin = FakePlugin("codex", "Codex", [matched, unmatched])
analyzer = FakeAnalyzer()
monkeypatch.setattr("headroom.learn.analyzer._detect_default_model", lambda: "gpt-4o")
monkeypatch.setattr("headroom.learn.registry.get_plugin", lambda name: plugin)
monkeypatch.setattr("headroom.learn.analyzer.SessionAnalyzer", lambda model=None: analyzer)
monkeypatch.setattr("os.cpu_count", lambda: 12)
result = runner.invoke(
main,
["learn", "--agent", "codex", "--project", str(project_path), "--apply", "--workers", "4"],
catch_exceptions=False,
)
assert result.exit_code == 0, result.output
assert "Path: " in result.output
assert "Analyzing with gpt-4o..." in result.output
assert "Recommendations: 1" in result.output
assert "[WROTE]" in result.output
assert "Rule 1" in result.output
assert plugin.scan_calls == [(matched, 4)]
assert analyzer.calls[0][0] is matched
assert plugin.writer.calls[0][2] is False
fix(learn): aggregate verbosity baselines across projects instead of overwriting (#1288) ## Description `headroom learn --verbosity --apply --all` was building the output-shaper's savings baseline from only **one** project. `_run_verbosity` wrote the savings ledger *inside* the per-project loop (`ledger.baseline = baseline; ledger.save(...)`), so each project replaced the previous baseline and only the last project processed survived — frequently a near-empty one. The synthetic-control estimate that `/stats` exposes (`savings.by_layer.output_shaping`) was then computed against a tiny, unrepresentative sample. This PR makes `--all` aggregate across every targeted project and write the ledger **once**, after the loop. Closes # ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `BaselineModel.merge()` / `_Accum.merge()` (`headroom/proxy/output_savings.py`): fold one baseline into another. The accumulators hold additive online stats (`n` / `sum` / `sumsq`), so merging is element-wise and order-independent — identical to having observed both corpora against a single model. - `_run_verbosity` (`headroom/cli/learn.py`): accumulate a single `BaselineModel` across all targeted projects and persist it once after the loop, instead of overwriting per project. The applied verbosity level now comes from the project with the most samples (strongest signal) rather than whichever sorted last. Single-project runs are unchanged (an aggregate of one). When no transcripts are found, it prints a clear message and writes nothing. - Tests: unit test for `BaselineModel.merge`; CLI test that `--all --apply` across two projects aggregates both strata (totals summed, not last-wins) and applies the busier project's level. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ uv run pytest tests/test_output_savings.py tests/test_cli_learn.py tests/test_verbosity_learn.py -q tests/test_output_savings.py ............................... [ 54%] tests/test_cli_learn.py ........... [ 73%] tests/test_verbosity_learn.py ............... [100%] ============================== 57 passed in 0.51s ============================== $ uv run ruff check headroom/cli/learn.py headroom/proxy/output_savings.py All checks passed! $ uv run mypy headroom/cli/learn.py headroom/proxy/output_savings.py Success: no issues found in 2 source files ``` ## Real Behavior Proof - Environment: macOS, Python 3.12.13, this branch off `upstream/main`. - Exact command / steps: `headroom learn --verbosity --apply --all` (run across a multi-project transcript corpus), then inspect `~/.headroom/output_savings.json` (`baseline.glob.n`); compared against `headroom learn --verbosity --apply` for a single busy project. - Symptom (pre-fix, installed build): `headroom learn --verbosity --apply --all` across a multi-project transcript corpus wrote `~/.headroom/output_savings.json` with `baseline.glob.n = 2` (the last project processed was a near-empty `…/venv/bin` dir), while targeting a single busy project gave `baseline.glob.n = 15658`. - With this change: the new CLI test (`test_verbosity_all_apply_aggregates_baselines_across_projects`) drives `--all --apply` over two projects (3 samples + 1 sample) and asserts the persisted ledger has `total_samples == 4` with both strata present, plus the busier project's level applied. - Observed result: aggregated baseline persisted once; both strata retained; level taken from the higher-sample project. - Not tested: re-running the patched `--all` end-to-end on a live multi-project machine (covered instead by the unit merge-math test and the faked-`analyze` CLI test). ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A — CLI/behavioral change. ## Additional Notes - No linked issue (`Closes #` left blank intentionally). - Documentation checklist item is N/A — no user-facing docs describe the per-project overwrite behavior. - Level-selection note: for `--all`, the applied verbosity level is now deterministic (most-samples project) instead of last-processed; this is the intended improvement, not a behavior to preserve. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: JD Davis <mxjerrett@gmail.com>
2026-06-30 15:37:37 +02:00
def test_verbosity_all_apply_aggregates_baselines_across_projects(
monkeypatch: pytest.MonkeyPatch, runner: CliRunner, tmp_path: Path
) -> None:
import json as _json
from headroom.proxy.output_savings import BaselineModel, SavingsLedger
# Two projects, each with a transcript dir holding a dummy session file
# (analyze is faked, so contents are irrelevant — only presence matters).
proj_a_dir = tmp_path / "a"
proj_b_dir = tmp_path / "b"
for d in (proj_a_dir, proj_b_dir):
d.mkdir()
(d / "s.jsonl").write_text("{}")
proj_a = SimpleNamespace(name="a", project_path=tmp_path / "src-a", data_path=proj_a_dir)
proj_b = SimpleNamespace(name="b", project_path=tmp_path / "src-b", data_path=proj_b_dir)
plugin = FakePlugin("claude", "Claude Code", [proj_a, proj_b])
# Per-project synthetic baselines. Project A has more samples, so its level
# must be the one applied.
base_a = BaselineModel()
for v in (100, 200, 300):
base_a.observe("opus|new_user_ask|s|tools", v)
base_b = BaselineModel()
base_b.observe("sonnet|unknown|m|notools", 50)
class _Profile:
def __init__(self, level: int) -> None:
self.level = level
self.confidence = "high"
self.source = "heuristic"
self.rationale = "test"
self.signals: dict[str, object] = {}
self.learned_at: str | None = None
def save(self, path: object) -> None:
Path(str(path)).write_text(_json.dumps({"level": self.level}))
results = {
str(proj_a.project_path): (_Profile(1), base_a),
str(proj_b.project_path): (_Profile(3), base_b),
}
def fake_analyze(session_paths, project_path, llm_judge=None): # noqa: ANN001, ANN201
return results[project_path]
monkeypatch.setattr("headroom.learn.registry.get_plugin", lambda name: plugin)
monkeypatch.setattr("headroom.learn.verbosity.analyze", fake_analyze)
monkeypatch.setenv("HEADROOM_WORKSPACE_DIR", str(tmp_path / "ws"))
result = runner.invoke(
main,
["learn", "--agent", "claude", "--verbosity", "--all", "--apply"],
catch_exceptions=False,
)
assert result.exit_code == 0, result.output
ledger = SavingsLedger.load(tmp_path / "ws" / "output_savings.json")
# Aggregated, not last-project-wins: both strata present and totals summed.
assert ledger.baseline.total_samples == 4
assert "opus|new_user_ask|s|tools" in ledger.baseline.strata
assert "sonnet|unknown|m|notools" in ledger.baseline.strata
assert "across 2 project(s)" in result.output
# The applied level comes from the project with the most samples (A → 1).
verbosity = _json.loads((tmp_path / "ws" / "verbosity.json").read_text())
assert verbosity["level"] == 1
def test_learn_reports_missing_requested_project_and_lists_discovered(
monkeypatch: pytest.MonkeyPatch, runner: CliRunner, tmp_path: Path
) -> None:
requested = tmp_path / "missing"
requested.mkdir()
discovered = SimpleNamespace(name="project-a", project_path=tmp_path / "project-a")
plugin = FakePlugin("claude", "Claude Code", [discovered])
monkeypatch.setattr("headroom.learn.analyzer._detect_default_model", lambda: "gpt-4o")
monkeypatch.setattr("headroom.learn.registry.get_plugin", lambda name: plugin)
monkeypatch.setattr("headroom.learn.analyzer.SessionAnalyzer", FakeAnalyzer)
result = runner.invoke(
main,
["learn", "--agent", "claude", "--project", str(requested)],
catch_exceptions=False,
)
assert result.exit_code == 0
assert f"No project data found for {requested.resolve()}" in result.output
assert "Available discovered projects:" in result.output
assert "[claude]" in result.output
def test_learn_analyze_all_uses_default_workers_and_prints_summary(
monkeypatch: pytest.MonkeyPatch, runner: CliRunner, tmp_path: Path
) -> None:
projects_a = [SimpleNamespace(name="a", project_path=tmp_path / "a")]
projects_b = [SimpleNamespace(name="b", project_path=tmp_path / "b")]
plugin_a = FakePlugin("codex", "Codex", projects_a)
plugin_b = FakePlugin("claude", "Claude Code", projects_b)
analyzer = FakeAnalyzer()
monkeypatch.setattr("headroom.learn.analyzer._detect_default_model", lambda: "gpt-4o")
monkeypatch.setattr(
"headroom.learn.registry.auto_detect_plugins",
lambda: [plugin_a, plugin_b],
)
monkeypatch.setattr("headroom.learn.analyzer.SessionAnalyzer", lambda model=None: analyzer)
monkeypatch.setattr("os.cpu_count", lambda: 12)
result = runner.invoke(main, ["learn", "--all"], catch_exceptions=False)
assert result.exit_code == 0, result.output
assert "Detected agents: Codex, Claude Code" in result.output
assert "Total: 2 projects, 2 failures, 2 recommendations" in result.output
assert plugin_a.scan_calls == [(projects_a[0], 8)]
assert plugin_b.scan_calls == [(projects_b[0], 8)]
def test_learn_analyze_all_continues_when_one_project_write_fails(
monkeypatch: pytest.MonkeyPatch, runner: CliRunner, tmp_path: Path
) -> None:
blocked = SimpleNamespace(name="blocked", project_path=tmp_path / "blocked")
ok = SimpleNamespace(name="ok", project_path=tmp_path / "ok")
plugin = FakePlugin("claude", "Claude Code", [blocked, ok])
plugin.writer.fail_for = blocked
analyzer = FakeAnalyzer()
monkeypatch.setattr("headroom.learn.analyzer._detect_default_model", lambda: "gpt-4o")
monkeypatch.setattr("headroom.learn.registry.get_plugin", lambda name: plugin)
monkeypatch.setattr("headroom.learn.analyzer.SessionAnalyzer", lambda model=None: analyzer)
result = runner.invoke(
main,
["learn", "--agent", "claude", "--all", "--apply"],
catch_exceptions=False,
)
assert result.exit_code == 0, result.output
assert "Warning: failed to write recommendations" in result.output
assert str(blocked.project_path) in result.output
assert "[WROTE]" in result.output
assert str(ok.project_path / "AGENTS.md") in result.output
2026-05-09 22:11:42 -07:00
expected_workers = min(os.cpu_count() or 4, 8)
assert plugin.scan_calls == [(blocked, expected_workers), (ok, expected_workers)]
def test_learn_handles_empty_sessions_and_no_pattern_outputs(
monkeypatch: pytest.MonkeyPatch, runner: CliRunner, tmp_path: Path
) -> None:
no_sessions = SimpleNamespace(name="empty", project_path=tmp_path / "empty")
no_failures = SimpleNamespace(name="clean", project_path=tmp_path / "clean")
no_actions = SimpleNamespace(name="no-actions", project_path=tmp_path / "no-actions")
class BranchingPlugin(FakePlugin):
fix(learn): scan subagent and workflow transcripts (#1045) ## Description `headroom learn` only scanned top-level main Claude Code sessions (`<project>/<uuid>.jsonl`). Nested subagent and workflow transcripts under `<project>/<uuid>/subagents/**` were not opened, which hid a large amount of tool-call failure and token-spend activity from failure mining and downstream analysis. This change makes the Claude scanner descend into nested transcripts by default and tag each `SessionData` with its source. `--main-only` restores the previous top-level-only scan scope. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - Updated `ClaudeCodePlugin.scan_project` to discover nested subagent and workflow transcripts by default. - Added source tagging for `main`, `subagent`, and `workflow` sessions. - Added `--main-only` and `include_subagents` plumbing so callers can opt back into top-level-only scanning. - Added the `include_subagents` scanner parameter to Codex/Gemini as a documented no-op because those scanners use flat session layouts. - Added regression tests for nested discovery, source tagging, parallel scanning, and CLI flag threading. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality ### Test Output ```text Full learn + CLI suite: # 186 passed, 2 skipped GitHub Actions CI for this PR: # build, build-wheel, lint, tests, e2e, CodeQL, and native wrapper checks passed ``` ## Real Behavior Proof - Environment: local Claude Code corpus with nested subagent/workflow transcripts. - Exact command / steps: Scanned the corpus with the previous top-level-only behavior and then with nested transcript discovery enabled. - Observed result: The scanner saw 24 sessions before and 306 sessions after descending into nested transcripts. - Not tested: Codex/Gemini nested transcript discovery, because those providers currently use flat session layouts and treat `include_subagents` as a no-op. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review --------- Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-06-16 12:20:37 -07:00
def scan_project(self, project, max_workers: int = 1, include_subagents: bool = True): # noqa: ANN001, ANN201
self.scan_calls.append((project, max_workers))
if project is no_sessions:
return []
return [SimpleNamespace(events=["event"], tool_calls=[], failure_count=0)]
class BranchingAnalyzer(FakeAnalyzer):
def analyze(self, project, sessions): # noqa: ANN001, ANN201
self.calls.append((project, sessions))
if project is no_failures:
return SimpleNamespace(
total_sessions=1,
total_calls=2,
total_failures=0,
failure_rate=0.0,
recommendations=[],
)
return SimpleNamespace(
total_sessions=1,
total_calls=2,
total_failures=1,
failure_rate=0.5,
recommendations=[],
)
plugin = BranchingPlugin("codex", "Codex", [no_sessions, no_failures, no_actions])
analyzer = BranchingAnalyzer()
monkeypatch.setattr("headroom.learn.analyzer._detect_default_model", lambda: "gpt-4o")
monkeypatch.setattr("headroom.learn.registry.get_plugin", lambda name: plugin)
monkeypatch.setattr("headroom.learn.analyzer.SessionAnalyzer", lambda model=None: analyzer)
result = runner.invoke(main, ["learn", "--agent", "codex", "--all"], catch_exceptions=False)
assert result.exit_code == 0, result.output
assert "No conversation data found." in result.output
assert "No failures or patterns found." in result.output
assert "No actionable patterns found." in result.output
fix(learn): scan subagent and workflow transcripts (#1045) ## Description `headroom learn` only scanned top-level main Claude Code sessions (`<project>/<uuid>.jsonl`). Nested subagent and workflow transcripts under `<project>/<uuid>/subagents/**` were not opened, which hid a large amount of tool-call failure and token-spend activity from failure mining and downstream analysis. This change makes the Claude scanner descend into nested transcripts by default and tag each `SessionData` with its source. `--main-only` restores the previous top-level-only scan scope. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - Updated `ClaudeCodePlugin.scan_project` to discover nested subagent and workflow transcripts by default. - Added source tagging for `main`, `subagent`, and `workflow` sessions. - Added `--main-only` and `include_subagents` plumbing so callers can opt back into top-level-only scanning. - Added the `include_subagents` scanner parameter to Codex/Gemini as a documented no-op because those scanners use flat session layouts. - Added regression tests for nested discovery, source tagging, parallel scanning, and CLI flag threading. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality ### Test Output ```text Full learn + CLI suite: # 186 passed, 2 skipped GitHub Actions CI for this PR: # build, build-wheel, lint, tests, e2e, CodeQL, and native wrapper checks passed ``` ## Real Behavior Proof - Environment: local Claude Code corpus with nested subagent/workflow transcripts. - Exact command / steps: Scanned the corpus with the previous top-level-only behavior and then with nested transcript discovery enabled. - Observed result: The scanner saw 24 sessions before and 306 sessions after descending into nested transcripts. - Not tested: Codex/Gemini nested transcript discovery, because those providers currently use flat session layouts and treat `include_subagents` as a no-op. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review --------- Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-06-16 12:20:37 -07:00
def test_learn_main_only_flag_threads_to_scanner(
monkeypatch: pytest.MonkeyPatch, runner: CliRunner, tmp_path: Path
) -> None:
project_path = tmp_path / "proj"
project_path.mkdir()
proj = SimpleNamespace(name="proj", project_path=project_path)
plugin = FakePlugin("codex", "Codex", [proj])
monkeypatch.setattr("headroom.learn.analyzer._detect_default_model", lambda: "gpt-4o")
monkeypatch.setattr("headroom.learn.registry.get_plugin", lambda name: plugin)
monkeypatch.setattr("headroom.learn.analyzer.SessionAnalyzer", FakeAnalyzer)
# Default: descend into subagent/workflow transcripts.
result = runner.invoke(main, ["learn", "--agent", "codex", "--all"], catch_exceptions=False)
assert result.exit_code == 0, result.output
assert plugin.last_include_subagents is True
# --main-only restricts to top-level main sessions.
plugin.last_include_subagents = None
result = runner.invoke(
main, ["learn", "--agent", "codex", "--all", "--main-only"], catch_exceptions=False
)
assert result.exit_code == 0, result.output
assert plugin.last_include_subagents is False
feat(learn): write per-project learnings to CLAUDE.local.md by default (#1115) ## Description `headroom learn` wrote per-project learnings into the project's `CLAUDE.md`, which Claude Code treats as team-shared and git-tracked. That meant machine-specific absolute paths and tool-discovery byproducts polluted the shared file for every teammate. This switches the default to the personal, gitignored `CLAUDE.local.md`, adds a `--target` override, and migrates any stale block out of `CLAUDE.md`. Closes #1072. ## Type of Change - [ ] Bug fix (non-breaking change that fixes an issue) - [x] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `ClaudeCodeWriter` now writes CONTEXT_FILE recommendations to `CLAUDE.local.md` by default instead of `CLAUDE.md` (the home-directory case still uses `~/.claude/CLAUDE.md`, which is personal global memory). - Added a `--target` flag (Claude Code only) and `set_context_target()` to override the destination — e.g. `--target CLAUDE.md` to opt back into the shared file, or any relative/absolute path. - On first run after upgrade, a stale Headroom block left in `CLAUDE.md` is moved into `CLAUDE.local.md` and stripped from `CLAUDE.md`, with a warning surfaced by the CLI. If `CLAUDE.md` held nothing but the block, the empty file is removed. - `WriteResult` carries `warnings`; the `learn` CLI prints them. - Updated docs (`failure-learning.mdx`) and `CHANGELOG.md`. This implements the maintainer's stated preference order from the issue (default → `CLAUDE.local.md`, plus a `--target` flag), scoped to the Claude writer only — `AGENTS.md`/`GEMINI.md` have no `.local` convention and are untouched. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ pytest tests/test_learn/ tests/test_cli_learn.py -q 196 passed, 2 skipped in 17.80s $ ruff check headroom/learn/writer.py headroom/cli/learn.py All checks passed! $ mypy headroom/learn/writer.py headroom/cli/learn.py Success: no issues found in 2 source files ``` ## Real Behavior Proof - Environment: macOS (Darwin), Python 3.11, headroom on rebased upstream/main - Exact command / steps: ran ClaudeCodeWriter against a temp project whose `CLAUDE.md` held hand-written content plus a legacy Headroom block, then `writer.write([...], dry_run=False)` - Observed result: `CLAUDE.md` kept its hand-written content with the block removed; `CLAUDE.local.md` gained both the migrated `### Old` section and the new `### Env` section; `result.warnings` contained the "Moved Headroom learnings out of …" notice. A block-only `CLAUDE.md` was deleted and a "Removed …" warning emitted. - Not tested: live end-to-end `headroom learn --apply` against real LLM analysis (writer + CLI plumbing covered by unit/CLI tests with mocked analysis) ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I have updated CHANGELOG.md if applicable ## Additional Notes Scoped to the Claude Code writer per the issue. After migration, `discover_projects` may briefly re-surface a section the LLM re-derives, but the write-side merge dedups by section name so the file stays correct.
2026-06-22 22:05:06 +02:00
class TargetAwareWriter(FakeWriter):
"""A writer that supports --target and surfaces a migration warning."""
def __init__(self) -> None:
super().__init__()
self.context_target: str | None = None
def set_context_target(self, target: str | None) -> None:
self.context_target = target
def write(self, recommendations, project, dry_run: bool): # noqa: ANN001, ANN201
self.calls.append((recommendations, project, dry_run))
return SimpleNamespace(
dry_run=dry_run,
content_by_file={
Path(project.project_path) / "CLAUDE.local.md": "<!-- headroom -->\nRule 1"
},
warnings=["Moved Headroom learnings out of CLAUDE.md into CLAUDE.local.md."],
)
def test_learn_target_threads_to_writer_and_prints_warnings(
monkeypatch: pytest.MonkeyPatch, runner: CliRunner, tmp_path: Path
) -> None:
project_path = tmp_path / "proj"
project_path.mkdir()
proj = SimpleNamespace(name="proj", project_path=project_path)
plugin = FakePlugin("claude", "Claude Code", [proj])
plugin.writer = TargetAwareWriter()
monkeypatch.setattr("headroom.learn.analyzer._detect_default_model", lambda: "gpt-4o")
monkeypatch.setattr("headroom.learn.registry.get_plugin", lambda name: plugin)
monkeypatch.setattr("headroom.learn.analyzer.SessionAnalyzer", FakeAnalyzer)
result = runner.invoke(
main,
[
"learn",
"--agent",
"claude",
"--project",
str(project_path),
"--apply",
"--target",
"CLAUDE.md",
],
catch_exceptions=False,
)
assert result.exit_code == 0, result.output
# --target is threaded into the writer...
assert plugin.writer.context_target == "CLAUDE.md"
# ...and the writer's warnings are surfaced to the user.
assert "Moved Headroom learnings" in result.output
def test_learn_target_ignored_for_unsupported_agent(
monkeypatch: pytest.MonkeyPatch, runner: CliRunner, tmp_path: Path
) -> None:
project_path = tmp_path / "proj"
project_path.mkdir()
proj = SimpleNamespace(name="proj", project_path=project_path)
# FakePlugin's FakeWriter has no set_context_target, so --target is unsupported.
plugin = FakePlugin("codex", "Codex", [proj])
monkeypatch.setattr("headroom.learn.analyzer._detect_default_model", lambda: "gpt-4o")
monkeypatch.setattr("headroom.learn.registry.get_plugin", lambda name: plugin)
monkeypatch.setattr("headroom.learn.analyzer.SessionAnalyzer", FakeAnalyzer)
result = runner.invoke(
main,
["learn", "--agent", "codex", "--project", str(project_path), "--target", "CLAUDE.md"],
catch_exceptions=False,
)
assert result.exit_code == 0, result.output
assert "Note: --target is not supported for codex" in result.output