2026-04-23 07:39:52 -05:00
|
|
|
from __future__ import annotations
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-05-09 22:11:42 -07:00
|
|
|
import os
|
2026-04-23 07:39:52 -05:00
|
|
|
from pathlib import Path
|
|
|
|
|
from types import SimpleNamespace
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
import click
|
|
|
|
|
import click.shell_completion as click_shell_completion
|
|
|
|
|
import pytest
|
|
|
|
|
from click.testing import CliRunner
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
from headroom.cli.learn import _AgentChoice
|
|
|
|
|
from headroom.cli.main import main
|
2026-04-24 15:33:30 +02:00
|
|
|
|
|
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
@pytest.fixture
|
|
|
|
|
def runner() -> CliRunner:
|
|
|
|
|
return CliRunner()
|
2026-04-24 15:33:30 +02:00
|
|
|
|
|
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
class FakeWriter:
|
|
|
|
|
def __init__(self) -> None:
|
|
|
|
|
self.calls: list[tuple[list[object], object, bool]] = []
|
2026-05-09 15:45:26 -07:00
|
|
|
self.fail_for: object | None = None
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
def write(self, recommendations, project, dry_run: bool): # noqa: ANN001, ANN201
|
|
|
|
|
self.calls.append((recommendations, project, dry_run))
|
2026-05-09 15:45:26 -07:00
|
|
|
if project is self.fail_for:
|
|
|
|
|
raise PermissionError(f"cannot write {project.project_path}")
|
2026-04-23 07:39:52 -05:00
|
|
|
return SimpleNamespace(
|
|
|
|
|
dry_run=dry_run,
|
|
|
|
|
content_by_file={
|
|
|
|
|
Path(project.project_path) / "AGENTS.md": "<!-- headroom -->\nRule 1\nRule 2"
|
|
|
|
|
},
|
|
|
|
|
)
|
2026-04-24 15:33:30 +02:00
|
|
|
|
|
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
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
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
def detect(self) -> bool:
|
|
|
|
|
return True
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
def create_writer(self) -> FakeWriter:
|
|
|
|
|
return self.writer
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
def discover_projects(self) -> list[object]:
|
|
|
|
|
return self._projects
|
2026-04-24 15:33:30 +02:00
|
|
|
|
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
|
2026-04-23 07:39:52 -05:00
|
|
|
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
|
2026-04-23 07:39:52 -05:00
|
|
|
return [SimpleNamespace(events=["event"], tool_calls=[], failure_count=0)]
|
2026-04-24 15:33:30 +02:00
|
|
|
|
|
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
class FakeAnalyzer:
|
|
|
|
|
def __init__(self, model: str | None = None) -> None:
|
|
|
|
|
self.model = model
|
|
|
|
|
self.calls: list[tuple[object, list[object]]] = []
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
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")],
|
|
|
|
|
)
|
2026-04-24 15:33:30 +02:00
|
|
|
|
|
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
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"],
|
|
|
|
|
)
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
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)
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
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]
|
2026-04-24 15:33:30 +02:00
|
|
|
|
|
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
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")),
|
|
|
|
|
)
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
result = runner.invoke(main, ["learn"], catch_exceptions=False)
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
assert result.exit_code == 1
|
|
|
|
|
assert "Error: no model" in result.output
|
2026-04-24 15:33:30 +02:00
|
|
|
|
|
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
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)
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
result = runner.invoke(main, ["learn"], catch_exceptions=False)
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
assert result.exit_code == 0
|
|
|
|
|
assert "No coding agent data found." in result.output
|
2026-04-24 15:33:30 +02:00
|
|
|
|
|
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
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])
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
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)
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
with runner.isolated_filesystem(temp_dir=tmp_path):
|
|
|
|
|
result = runner.invoke(main, ["learn", "--agent", "codex"], catch_exceptions=False)
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
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
|
2026-04-24 15:33:30 +02:00
|
|
|
|
|
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
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()
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
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)
|
2026-05-09 16:00:10 -07:00
|
|
|
monkeypatch.setattr("os.cpu_count", lambda: 12)
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
result = runner.invoke(
|
|
|
|
|
main,
|
|
|
|
|
["learn", "--agent", "codex", "--project", str(project_path), "--apply", "--workers", "4"],
|
|
|
|
|
catch_exceptions=False,
|
|
|
|
|
)
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
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
|
2026-04-24 15:33:30 +02:00
|
|
|
|
|
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
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])
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
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)
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
result = runner.invoke(
|
|
|
|
|
main,
|
|
|
|
|
["learn", "--agent", "claude", "--project", str(requested)],
|
|
|
|
|
catch_exceptions=False,
|
|
|
|
|
)
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
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
|
2026-04-24 15:33:30 +02:00
|
|
|
|
|
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
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()
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
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)
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
result = runner.invoke(main, ["learn", "--all"], catch_exceptions=False)
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
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)]
|
2026-04-24 15:33:30 +02:00
|
|
|
|
|
|
|
|
|
2026-05-09 15:45:26 -07:00
|
|
|
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)]
|
2026-05-09 15:45:26 -07:00
|
|
|
|
|
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
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")
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
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
|
2026-04-23 07:39:52 -05:00
|
|
|
self.scan_calls.append((project, max_workers))
|
|
|
|
|
if project is no_sessions:
|
|
|
|
|
return []
|
|
|
|
|
return [SimpleNamespace(events=["event"], tool_calls=[], failure_count=0)]
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
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=[],
|
|
|
|
|
)
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
plugin = BranchingPlugin("codex", "Codex", [no_sessions, no_failures, no_actions])
|
|
|
|
|
analyzer = BranchingAnalyzer()
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
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)
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
result = runner.invoke(main, ["learn", "--agent", "codex", "--all"], catch_exceptions=False)
|
2026-04-24 15:33:30 +02:00
|
|
|
|
2026-04-23 07:39:52 -05:00
|
|
|
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
|