Commit graph

46 commits

Author SHA1 Message Date
Jerrett Davis
db7e74187a fix(learn): surface Codex analysis failures 2026-08-13 22:47:15 -05:00
Abhay Singh
a24fe7dcbf
fix(learn): stop classifying a successful exit code 0 as an error (#2289)
## Description

`is_error_content` classifies successful shell commands as errors,
inflating the failure stats that `headroom learn` reports.

The heuristic flags a tool result as an error when it contains any of a
list of substrings, one of which is the bare `"exit code"`:

```python
indicators = [
    ..., "timed out", "exit code", "FileNotFoundError",
]
return any(ind in snippet for ind in indicators)
```

But agent harnesses (Codex, Grok, opencode, ...) append `exit code 0` to
the output of every **successful** shell command. `"exit code" in
snippet` is `True` for `exit code 0`, so those successes are counted as
failures.

That is not cosmetic: `is_error_content` sets `ToolCall.is_error`, which
feeds:
- the per-project failure rate the digest shows the LLM
(`_build_digest`: "N failures (X%)"), and
- loop classification (`detect_loops` treats a group as an *error loop*
when ≥ half its calls are errors),

so a project where most shell commands succeed can read as one riddled
with failures, biasing the learned recommendations.

## Fix

Match a **nonzero** exit code instead of the bare substring:

```python
_NONZERO_EXIT_RE = re.compile(r"exit code:?\s*(?!0\b)\d", re.IGNORECASE)
...
if any(ind in snippet for ind in indicators):
    return True
return bool(_NONZERO_EXIT_RE.search(snippet))
```

`exit code 0` no longer matches. A nonzero code still does — and, as a
small bonus, the case-insensitive regex now also catches `Exit code: 1`
(colon + capitalized), which the old case-sensitive lowercase substring
missed.

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

- `headroom/learn/_shared.py`: replace the `"exit code"` substring
indicator with a nonzero-exit-code regex (`_NONZERO_EXIT_RE`) checked
after the other indicators.
- `tests/test_learn/test_integration.py`: new tests that `exit code 0`
is not an error and a nonzero code (any casing / with a colon) still is.
- `CHANGELOG.md`: Bug Fixes entry.

## Testing

- [ ] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed

### Test Output

```text
$ uvx ruff@0.15.17 check headroom/learn/_shared.py tests/test_learn/test_integration.py
All checks passed!
$ uvx mypy@1.20.2 --ignore-missing-imports headroom/learn/_shared.py
Success: no issues found in 1 source file
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12, `uvx ruff@0.15.17` / `uvx
mypy@1.20.2`. A full `pytest` OOM-kills this box (ML stack import), so I
reproduced the classifier with a dependency-free script and left the
full pytest to CI.
- Exact command / steps: ran a successful output ending `Process
finished with exit code 0`, plus several nonzero-code failures (`exit
code 1`, `Exit code: 127`, `exit code 137`) and control strings, through
the OLD substring form and the NEW regex form.
- Observed result: OLD flags `exit code 0` as an error; NEW returns
`False` for it, still returns `True` for every nonzero code (including
the colon/capitalized form the old lowercase substring missed), and
leaves the other indicators unchanged.
- Not tested: a full `learn` run over a real history; full local
`pytest` deferred to CI (OOM).

## 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
- [ ] New and existing unit tests pass locally with my changes
- [x] I have updated the CHANGELOG.md if applicable

## Additional Notes

The "unit tests pass locally" box is unchecked because the full suite
imports the ML stack, which I can't run here. The new tests live
alongside the existing `is_error_content` false-positive/true-positive
tests in `tests/test_learn/test_integration.py`, so they run under the
normal CI pytest job; behaviour is additionally verified by the
standalone proof above.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-08-11 23:45:32 -05:00
Abhay Singh
29d8a5e563
fix(learn/gemini): stop double-counting session tokens (#2230)
## Description

The Gemini `learn` scanner inflates every session's token totals by
double-counting.

In `_parse_messages` the per-message usage accumulation is:

```python
usage = msg.get("usageMetadata", msg.get("usage", {}))
if isinstance(usage, dict):
    total_input_tokens += usage.get("promptTokenCount", 0)
    total_input_tokens += usage.get("cachedContentTokenCount", 0)
    total_output_tokens += usage.get("candidatesTokenCount", 0)
    total_output_tokens += (
        usage.get("totalTokenCount", 0) - usage.get("promptTokenCount", 0)
        if usage.get("totalTokenCount")
        else 0
    )
```

Both additions on each side double-count, per Gemini's `usageMetadata`
semantics:

- `cachedContentTokenCount` is the cached **subset** of
`promptTokenCount`, not tokens on top of it. Adding both counts the
cached input twice.
- `totalTokenCount == promptTokenCount + candidatesTokenCount`, so
`totalTokenCount - promptTokenCount` is just `candidatesTokenCount`
again. Adding it on top of `candidatesTokenCount` counts the output
twice.

For a turn with 1000 prompt tokens (300 cached) and 500 output tokens
(`totalTokenCount` 1500), the scanner records input 1300 and output 1000
instead of 1000 / 500 — so both totals are materially inflated for any
Gemini session that carries usage metadata.

## Fix

Count the prompt as input and the candidates as output, once each:

```python
total_input_tokens += usage.get("promptTokenCount", 0)
total_output_tokens += usage.get("candidatesTokenCount", 0)
```

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

- `headroom/learn/plugins/gemini.py`: drop the `cachedContentTokenCount`
and `totalTokenCount - promptTokenCount` additions in `_parse_messages`.
- `tests/test_learn/test_gemini_scanner.py`: new test asserting the
input/output totals equal `promptTokenCount` / `candidatesTokenCount`.
- `CHANGELOG.md`: Bug Fixes entry.

## Testing

- [ ] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed

### Test Output

```text
$ uvx ruff@0.15.17 check headroom/learn/plugins/gemini.py tests/test_learn/test_gemini_scanner.py
All checks passed!
$ uvx mypy@1.20.2 --ignore-missing-imports headroom/learn/plugins/gemini.py
Success: no issues found in 1 source file
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12, `uvx ruff@0.15.17` / `uvx
mypy@1.20.2`. A full `pytest` OOM-kills this box (ML stack import), so I
reproduced the arithmetic with a dependency-free script and left the
full pytest to CI.
- Exact command / steps: fed a usage dict of `promptTokenCount=1000,
cachedContentTokenCount=300, candidatesTokenCount=500,
totalTokenCount=1500` through the OLD accumulation and the NEW one.
- Observed result: OLD → input 1300, output 1000 (cached and candidates
both counted twice); NEW → input 1000, output 500 (the true figures).
- Not tested: a full `learn` run over a real Gemini history; full local
`pytest` deferred to CI (OOM).

## 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
- [ ] New and existing unit tests pass locally with my changes
- [x] I have updated the CHANGELOG.md if applicable

## Additional Notes

The "unit tests pass locally" box is unchecked because the full suite
imports the ML stack, which I can't run here. The new test uses the
existing `GeminiScanner` harness in
`tests/test_learn/test_gemini_scanner.py` so it runs under the normal CI
pytest job; behaviour is additionally verified by the standalone proof
above.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-08-11 23:37:58 -05:00
Abhay Singh
7e83b8da3c
fix(learn/gemini): detect the project path for JSONL sessions (#2229)
## Description

The Gemini `learn` plugin can't detect the project path for JSONL
sessions, so it writes its insights to the wrong project.

`discover_projects` globs both `session-*.json` and `session-*.jsonl`
and calls `_detect_project_path`, which reads the file with a single
whole-file `json.load`:

```python
def _detect_project_path(self, session_path: Path) -> Path | None:
    try:
        with open(session_path, encoding="utf-8", errors="replace") as f:
            data = json.load(f)
    except (OSError, json.JSONDecodeError):
        return None
    ...
```

A `.jsonl` session is one JSON object per line, so `json.load` on the
whole file raises `json.JSONDecodeError` ("Extra data") on the second
line. The method swallows that and returns `None`, and the caller falls
back to `Path.cwd()`:

```python
project_path = self._detect_project_path(session_files[0])
...
ProjectInfo(
    name=project_path.name if project_path else project_dir.name,
    project_path=project_path or Path.cwd(),   # wrong project
    context_file=gemini_md,                    # None: GEMINI.md never found
    ...
)
```

So for the JSONL format (Gemini CLI's newer session format — the one
that carries `type: "session_metadata"` records), detection never works:
the learned tool/verbosity insights are attributed to the current
working directory instead of the real project, and the project's
`GEMINI.md` is never located. The sibling `_scan_jsonl_session` already
reads this format line-by-line, and the Claude plugin recovers the
project path from session `cwd` the same way.

## Fix

Route `.jsonl` sessions through a line-by-line reader and share the
field extraction (`projectPath` / `project_path` / `cwd` /
`workingDirectory`) between both formats:

```python
if session_path.suffix == ".jsonl":
    return self._detect_project_path_jsonl(session_path)
```

`_detect_project_path_jsonl` parses each line (skipping blanks and
unparseable lines, exactly like `_scan_jsonl_session`) and returns the
first record that yields an existing path. The JSON path is unchanged.

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

- `headroom/learn/plugins/gemini.py`: dispatch `.jsonl` sessions to a
new line-by-line `_detect_project_path_jsonl`; factor the field
extraction into `_project_path_from_entry` shared by both paths.
- `tests/test_learn/test_gemini_scanner.py`: new test asserting a JSONL
session's `cwd` is recovered.
- `CHANGELOG.md`: Bug Fixes entry.

## Testing

- [ ] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed

### Test Output

```text
$ uvx ruff@0.15.17 check headroom/learn/plugins/gemini.py tests/test_learn/test_gemini_scanner.py
All checks passed!
$ uvx mypy@1.20.2 --ignore-missing-imports headroom/learn/plugins/gemini.py
Success: no issues found in 1 source file
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12, `uvx ruff@0.15.17` / `uvx
mypy@1.20.2`. A full `pytest` OOM-kills this box (ML stack import), so I
reproduced the behavior with a dependency-free script mirroring both
detection paths and left the full pytest to CI.
- Exact command / steps: wrote a `.jsonl` session whose first record is
`{"type":"session_metadata","cwd":"<project>"}`, then ran the OLD
whole-file `json.load` reader and the NEW line-by-line reader; also
checked a single-object `.json` session still resolves under both.
- Observed result: OLD returns `None` for the JSONL file (the caller
would fall back to cwd); NEW returns the project path; the `.json` case
resolves identically under both.
- Not tested: a full `learn` run over a real Gemini history; full local
`pytest` deferred to CI (OOM).

## 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
- [ ] New and existing unit tests pass locally with my changes
- [x] I have updated the CHANGELOG.md if applicable

## Additional Notes

The "unit tests pass locally" box is unchecked because the full suite
imports the ML stack, which I can't run here. The new test uses the
existing `GeminiScanner` harness in
`tests/test_learn/test_gemini_scanner.py` so it runs under the normal CI
pytest job; behaviour is additionally verified by the standalone proof
above.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-08-11 23:37:37 -05:00
Tejas Chopra
e0ce4b1d48
fix: remove rtk and lean-ctx CLI context tools (#2677)
## Description

Removes both third-party CLI context tools — **rtk** and **lean-ctx** —
and with them the context-tool selector itself. Headroom no longer
downloads, installs or configures either one, and there is no
replacement.

The previous pass (#2344) gated only three entry points inside
`headroom/cli/wrap.py`. That left the feature reachable in practice:

| Gap | Effect |
|---|---|
| `scripts/install.sh:1544`, `install.ps1:1681` | Ran `rtk init --global
--auto-patch` from bash/PowerShell, **bypassing the Python gate
entirely** — `curl \| sh` still wrote a Claude Code `PreToolUse` hook
regardless of `HEADROOM_RTK` |
| `wrap.py` `_setup_context_tool_for_agent` | **`wrap openhands` was
broken by default**: `rtk_required=True` met a gate returning `None` →
`SystemExit(1)`. Invisible because all 8 openhands tests patched
`_ensure_rtk_binary` to a fake path |
| `proxy/helpers.py`, `subscription/tracker.py` | Proxy shelled out to
`rtk gain` from `/stats`, the dashboard and `headroom perf`; the tracker
polled it per contribution (`_RTK_WIRING_DEFAULT = "enabled"`) |
| No cleanup path | Nothing removed artifacts an earlier default had
installed, so a machine that once ran the old default kept rtk in the
loop forever (#1669, #1955) |

Also worth noting: the rtk binary download had **no SHA or signature
verification** — only `rtk --version` as a smoke test.

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [x] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [x] Code refactoring (no functional changes)

## Changes Made

**Removed** — `headroom/rtk/` and `headroom/lean_ctx/` packages,
`headroom/cli/wrap_rtk_metrics.py`, `_selected_context_tool` /
`_setup_context_tool_for_agent` / `_VALID_CONTEXT_TOOLS`, the `--rtk` /
`--no-rtk` / `--no-project-rtk` / `--keep-rtk` flags across all 18 wrap
subcommands, `HEADROOM_RTK*`, the proxy-side `rtk gain` polling, the
dashboard CLI-filtering panel (rows + all 8 `cliFiltering*` Alpine
getters), `paths.rtk_path()` / `lean_ctx_path()`, the SDK path helpers,
`benchmarks/rtk_loop_learn_eval.py`, and the `headroom/rtk/**` CI path
filters.

**Fails loudly, not silently** — `--context-tool` / `--no-context-tool`
/ `HEADROOM_CONTEXT_TOOL` are kept solely to error out. They live in
shell profiles, aliases and CI jobs, and accepting them as a no-op would
read as Headroom having quietly stopped working. The installers reject
them too, which matters more than it looks: their arg parsers forward
the first unknown flag **and everything after it** to the wrapped tool,
so a leftover `--no-rtk` would have silently swallowed a following
`--port` and then been ignored downstream.

**New `headroom/context_tool_cleanup.py`** — deleting the code cannot
help a machine that already ran the old default, since the hooks,
binaries and injected guidance are durable on disk.
`purge_context_tool_artifacts()` runs once per `wrap`/`unwrap` and
removes the registered hook entries, the generated hook scripts, the
Headroom-managed `~/.local/bin` symlinks, the vendored
`~/.headroom/bin/{rtk,lean-ctx}` binaries, the `lean-ctx` MCP server
entry and the marker-fenced instruction blocks. Deliberately
conservative: idempotent, **skips** a malformed config rather than
overwriting it, and only unlinks a symlink resolving inside Headroom's
own bin dir so a user's own build is untouched. It reports on
**stderr**, because `wrap/unwrap openclaw --prepare-only` emit
machine-readable JSON on stdout as their entire contract. Skipped for
`wrap selfheal` (runs from a SessionStart hook; must not race Claude
Code's writer for `~/.claude.json`) and for `--help`, which must stay
read-only.

**Client-config hardening** (discovered while investigating a "corrupted
Serena settings file" report) — `wrap.py` reset a settings file to `{}`
when an existing file would not parse, then wrote that back. One
hand-edited typo or a transient `EACCES`/`EINTR` on a valid file
destroyed the user's `permissions`, `env` and `hooks`, on **every
`headroom wrap claude`**. It now refuses to write. Separately,
`fsutil.write_text` is now atomic (temp file + `fsync` + `os.replace`),
fixing all 14 non-atomic client-config writes at once; it follows
symlinks rather than replacing them (dotfile managers) and preserves an
existing file's mode.

**Deliberately kept** — `rtk` stays in the wrapper-peel list in
`transforms/content_router.py`. It sits beside `sudo`/`env`/`timeout` as
shell-command grammar, so `rtk cat f` is still classified as a file read
for anyone running their own rtk install, which the purge intentionally
leaves alone.

## 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
$ ruff check headroom/ tests/ e2e/ --exclude headroom/dashboard/templates
All checks passed!

$ ruff format --check headroom/ tests/ e2e/ --exclude headroom/dashboard/templates
1255 files already formatted

$ mypy headroom/
Success: no issues found in 508 source files

$ pytest tests/test_context_tool_cleanup.py -q
11 passed

$ pytest tests/test_fsutil.py -q
12 passed

$ pytest tests/test_cli/test_wrap_codex.py -q            # 89 tests
89 passed in 431.68s
$ pytest tests/test_cli/test_wrap_opencode.py -q
39 passed in 257.46s
$ pytest tests/test_cli/test_wrap_helpers.py -q
45 passed
$ pytest tests/test_paths.py -q
75 passed
$ pytest tests/test_cli/test_unwrap_claude.py -q
14 passed
$ pytest tests/test_proxy_savings_history.py -q
39 passed
$ pytest tests/test_cli/test_wrap_copilot.py -q
27 passed
$ pytest tests/test_cli/test_wrap_zcode.py -q
20 passed
$ pytest tests/test_subscription_tracker.py -q
9 passed
$ pytest tests/test_proxy_dashboard_stats_cache.py -q
5 passed, 1 skipped
```

Repo-wide grep for 14 removed symbols (`headroom.rtk`,
`headroom.lean_ctx`, `_ensure_rtk_binary`, `_selected_context_tool`,
`_get_context_tool_stats`, `rtk_path`, `lean_ctx_path`,
`wrap_rtk_metrics`, `HEADROOM_RTK`, `cli_tokens_avoided`,
`tokens_saved_rtk`, …) across `*.py`, `*.ts`, `*.sh`, `*.ps1`, `*.yml`,
`*.html`: **zero hits**.

Notable test changes: `test_wrap_openhands.py` no longer patches
`_ensure_rtk_binary` and asserts `wrap openhands --prepare-only` exits 0
unpatched — the regression that was previously masked.
`test_wrap_continue.py` and `test_wrap_hintfile_agents.py` were removed
(every test drove RTK instruction injection). A new
`test_subscription_tracker.py::test_load_state_written_before_cli_context_tools_were_removed`
proves a pre-removal `subscription_state.json` still loads.

## Real Behavior Proof

- **Environment:** macOS 15.4 (darwin 25.4.0), Python 3.12.6, Headroom @
this branch, real `~/.headroom` and `~/.claude` on the dev machine.
- **Exact command / steps and observed result:**

```text
# 1. Retired flag fails loudly instead of silently no-op'ing
$ headroom wrap codex --prepare-only --context-tool rtk
Error: CLI context tools (rtk, lean-ctx) have been removed from Headroom: they
rewrote shell commands through a third-party binary Headroom no longer manages.
Drop --context-tool / --no-context-tool and unset HEADROOM_CONTEXT_TOOL;
`headroom wrap` uninstalls what they left behind on first run.

$ HEADROOM_CONTEXT_TOOL=lean-ctx headroom wrap codex --prepare-only
Error: CLI context tools (rtk, lean-ctx) have been removed from Headroom: ...

# 2. install.sh rejects the retired flags (extracted parse_wrap_args harness)
['--no-rtk', '--port', '9999']   rc=1  ERROR: CLI context tools ... Drop --no-rtk
['--context-tool=rtk']           rc=1  ERROR: CLI context tools ... Drop --context-tool
$ bash -n scripts/install.sh   # syntax OK

# 3. Purge ran against the real machine, which had all the orphaned artifacts
$ python -c "from headroom.context_tool_cleanup import purge_context_tool_artifacts; ..."
  removed ~/.headroom/bin/lean-ctx        (51 MB)
  removed ~/.headroom/bin/rtk             (7.7 MB)
  removed ~/.local/bin/rtk                (symlink into ~/.headroom/bin)
  removed ~/.claude/hooks/rtk-rewrite.sh
  removed 8 lean-ctx-* hook scripts
# ~/.claude.json afterwards: 90 top-level keys, 19 projects, mcpServers unchanged
# → ~59 MB reclaimed, no unrelated key touched

# 4. stdout stays machine-readable while the purge reports (planted a fake artifact)
$ headroom wrap openclaw --prepare-only --gateway-provider-id codex >out 2>err
$ cat out
{"enabled":true,"config":{"proxyPort":8787,...}}     # parses as JSON
$ cat err
Retired CLI context tool cleanup: removed /Users/tcms/.headroom/bin/rtk

# 5. --help is inert (planted artifact survives), a real run purges
$ headroom wrap codex --help   → artifact survived: CORRECT
$ headroom wrap openclaw --prepare-only → purged: CORRECT

# 6. MCP purge dry-run against a copy of the real 82 KB ~/.claude.json
top-level keys 90 -> 90;  projects 19 -> 19;  LOST keys: none
all content outside mcpServers byte-identical: True
```

Dashboard rendered via the Playwright test after the panel removal:
"Token Savings" shows only `Proxy 0 (0.0%)` / `Of total wire: 36.86%`,
and "Token Usage" reads Before Compression → Proxy Removed → After
Compression with no "Filtered (this session)" row. Nothing below the
removed panel broke.

- **Not tested:** Windows and Linux (macOS only) — `install.ps1` is
verified by brace-balance and inspection, not executed, since no `pwsh`
is available locally. The wrap e2e suite (`e2e/wrap/run.py`) was updated
but not run; it needs the Docker e2e image. `serena project index`
interaction is exercised in the stacked base PR.

## 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 did **not** edit `CHANGELOG.md` — it is generated by
release-please from my Conventional Commit PR title (a CI guard enforces
this)

## Additional Notes

**Stacked on #2676** (`tejas/serena-config-bootstrap`) — please merge
that first; this PR's base should then be retargeted to `main`, or it
will read as containing that fix too.

**Breaking-change migration for users:**
- Drop `--rtk`, `--no-rtk`, `--no-project-rtk`, `--keep-rtk`,
`--context-tool`, `--no-context-tool` from any alias, script or CI job,
and unset `HEADROOM_RTK*` / `HEADROOM_CONTEXT_TOOL`. They now error
rather than being ignored, so the failure is immediate and
self-explaining.
- Previously-installed artifacts are purged automatically on the next
`wrap`/`unwrap`; no manual cleanup needed.
- `headroom perf --json` no longer carries a `cli_filtering` key, and
`/stats` no longer returns a `context_tool` section.

**Docs:** `docs/rtk-architecture.md` deleted; RTK/lean-ctx removed from
`README.md`,
`docs/content/docs/{configuration,opencode,grok-build,docker-install,filesystem-contract}.mdx`,
`docs/observability.md` and the matching `wiki/` pages.
`REALIGNMENT/09-phase-G-rtk-observability.md` is marked SUPERSEDED
rather than deleted, to keep the planning record.

**Follow-ups not in scope:** `_emit_wrap_interrupted` was deleted as
dead code — its only caller was the `except KeyboardInterrupt` guarding
the binary download, so with no download there is nothing slow left to
interrupt.
2026-07-30 22:59:41 -07:00
TenderDeve
85e8699451
fix(learn): keep traceback tail in tool-error digest preview (#2596)
## Description

`_format_tool_call` in `headroom/learn/analyzer.py` built the error
preview with a head-only slice — `tc.output[:200]`. For tracebacks the
root cause (`ExceptionType: message`) is at the **tail**, so the digest
showed only `Traceback (most recent call last):` plus the first frame
and dropped the actual diagnosis. The issue reports 46% of 715 measured
errors were truncated past the 200-char head.

Closes #2590

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)

## Changes Made

- Added `_truncate_head_tail()` helper that collapses newlines and, when
over budget, keeps both the head and the tail joined by `…`.
- `_format_tool_call` now uses it for error output so the exception line
survives truncation. Short errors are returned unchanged (no marker).

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality

### Test Output

```text
$ uv run pytest tests/test_learn/test_analyzer.py::TestDigestBuilder -q
9 passed in 1.47s

$ uv run ruff check headroom/learn/analyzer.py tests/test_learn/test_analyzer.py
All checks passed!
```

## Real Behavior Proof

- Environment: headroom @ main, Python 3.14, uv
- Exact command / steps: added a long synthetic traceback (`KeyError:
'the-actual-root-cause'` at the tail) as a failing tool call and built
the digest.
- Observed result: digest now contains both `Traceback` and `KeyError:
'the-actual-root-cause'`, separated by `…`; short errors have no `…`.
- Not tested: mypy not run locally.

## 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] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective
- [x] New and existing unit tests pass locally with my changes
- [x] I did **not** edit `CHANGELOG.md`

## Additional Notes

Truncation budget stays at 200 chars (now split head/tail). mypy not run
locally; happy to adjust if CI flags anything.
2026-07-27 06:44:51 -07:00
Rod Boev
f74d874777
fix(learn): detect the active OpenCode database (#2587)
## Description

`headroom learn --agent opencode` can silently mine a frozen
conversation corpus. `OpenCodePlugin` hardcodes
`~/.local/share/opencode/opencode.db`, but source-built OpenCode writes
`opencode-local.db` in the same directory. When both files exist, learn
still succeeds against the stale packaged DB and ignores the live
source-built corpus.

This follows the report in
https://github.com/headroomlabs-ai/headroom/issues/2581 and builds on
the existing OpenCode learn path introduced in
https://github.com/headroomlabs-ai/headroom/pull/559.

This change keeps explicit constructor paths authoritative, honors
`HEADROOM_OPENCODE_DB` when it is set, and otherwise selects the newest
existing database between `opencode.db` and `opencode-local.db`,
preferring canonical `opencode.db` on exact ties. It also updates the
OpenCode learn docs line so the documented behavior matches the landed
resolver. Closes #2581.

The branch also carries one narrow CI repair requested during review:
`headroom/cli/wrap.py` now binds the `unwrap claude` Click command back
to `unwrap_claude` instead of the leak-warning helper, which restores
the existing unwrap test surface and leaves the helper as an internal
warning function.

## 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

- add a private OpenCode DB resolver in
`headroom/learn/plugins/opencode.py` with precedence `db_path` then
`HEADROOM_OPENCODE_DB` then newest existing default filename then
canonical fallback
- preserve canonical `opencode.db` for exact mtime ties and for
canonical-only installs
- add focused regression coverage for newer-local, explicit-path,
canonical-only, equal-tie, missing-override, and end-to-end scanning
cases
- sync the OpenCode learn docs paragraph so it no longer claims
`opencode.db` is the only supported default path
- restore the `unwrap claude` Click command binding in
`headroom/cli/wrap.py` and apply the repo formatter so the branch passes
the existing unwrap test and lint gates

## Testing

- [x] Unit tests pass (`uv run pytest
tests/test_learn/test_opencode_scanner.py -q`)
- [x] Linting passes (`uv run ruff check
headroom/learn/plugins/opencode.py
tests/test_learn/test_opencode_scanner.py`)
- [x] Type checking passes (`uv run mypy
headroom/learn/plugins/opencode.py`)
- [x] New tests added for new functionality when applicable
- [x] Manual testing performed

### Test Output

```text
uv run pytest tests/test_learn/test_opencode_scanner.py -q -k "newer_local_database"
1 passed, 9 deselected in 0.26s

uv run pytest tests/test_learn/test_opencode_scanner.py -q
10 passed in 0.50s

uv run ruff check headroom/learn/plugins/opencode.py tests/test_learn/test_opencode_scanner.py
All checks passed!

uv run ruff format headroom/learn/plugins/opencode.py tests/test_learn/test_opencode_scanner.py --check
2 files already formatted

uv run mypy headroom/learn/plugins/opencode.py
Success: no issues found in 1 source file

rg -n "opencode-local\.db|HEADROOM_OPENCODE_DB|opencode\.db" docs/content/docs/opencode.mdx
78:`headroom learn` supports OpenCode as a scan target. It reads past sessions from the newer of `~/.local/share/opencode/opencode-local.db` and `~/.local/share/opencode/opencode.db`, or from `HEADROOM_OPENCODE_DB` when you set an explicit override, and writes corrections to your project's `AGENTS.md`.

uv run pytest tests/test_cli/test_unwrap_claude.py -q -k "removes_mcp_rtk_and_stops_proxy or preserves_user_managed_serena or removes_headroom_installed_serena or keep_flags_skip_cleanup or restores_all_base_url_modes or stops_claude_owned_persistent_deployment or reports_ambiguous_same_port_persistent_deployment or warns_about_same_port_inherited_env or ignores_malformed_inherited_env_port"
9 passed, 5 deselected in 0.40s

uv run ruff check .
All checks passed!

uv run ruff format --check .
1340 files already formatted
```

## Real Behavior Proof

- Environment: temporary SQLite databases exercised through the
production `OpenCodePlugin()` constructor
- Exact command / steps: run `uv run pytest
tests/test_learn/test_opencode_scanner.py -q -k "newer_local_database"`
against `origin/main` with the new regression test overlaid, then run
the same command and the full `uv run pytest
tests/test_learn/test_opencode_scanner.py -q` suite on the branch head
- Observed result: the base reproduction fails with `AssertionError:
assert 'Canonical' == 'Local'`, proving current main still selects the
stale canonical DB; the branch head passes the reproduction row and the
full 10-test scanner suite
- Not tested: live user OpenCode corpus

## 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
- [ ] 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
- [ ] I have updated the CHANGELOG.md if applicable

## Additional Notes

- `CHANGELOG.md` stays untouched because Headroom generates release
notes from conventional commits.
- The automatic chooser is intentionally limited to the two known
default filenames, `opencode.db` and `opencode-local.db`. Other layouts
can use `HEADROOM_OPENCODE_DB`.
- The fix stays inside `headroom/learn/plugins/opencode.py`; no
provider-neutral learn or pipeline code changes are planned.
2026-07-26 19:50:59 -07:00
Parideboy
a09ba6c087
fix(learn): treat unreadable candidate paths as absent in project decode (#2446)
## Description

`headroom learn` crashes with an uncaught `PermissionError` when the
current user's username contains a dash. `_decode_project_path` (in
`headroom/learn/plugins/claude.py`) probes speculative candidate paths
when reconstructing an original filesystem path from a Claude Code
encoded project directory name. When the username is e.g. `marco-rocha`,
one candidate becomes `/home/marco/rocha`, which can collide with
another user's home directory whose parent isn't stat-able.
`Path.exists()` calls `os.stat` internally, raising `PermissionError`
instead of returning `False`, so the whole `learn` command crashes
before returning any recommendations.

Fixes #2443

## 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

- Add `_path_exists()` to `headroom/learn/plugins/claude.py` — a thin
wrapper around `Path.exists()` that returns `False` on any `OSError`
(including `PermissionError`), mirroring the existing `OSError` handling
already used in `_greedy_path_decode`.
- Route every speculative candidate-path existence check in the decode
path through `_path_exists()`: the Windows drive/path probes in
`_decode_windows_path`, the `simple` POSIX candidate and greedy-branch
bases in `_decode_project_path`/`_greedy_path_decode`, and the decoded
`project_path`/`CLAUDE.md` checks in `discover_projects`.
- Add regression tests covering the exact issue shape (`PermissionError`
on `/home/marco/rocha`) and the `_path_exists` helper directly.
- Leave `CHANGELOG.md` untouched — release-please generates it from
conventional commits.

## Testing

- [x] Unit tests pass (`python -m pytest
tests/test_learn/test_scanner.py::TestDecodePermissionError -q`)
- [x] Linting passes (`ruff check`, `ruff format --check` on the two
changed files)
- [x] New tests added for new functionality

### Test Output

```text
$ python -m pytest tests/test_learn/test_scanner.py::TestDecodePermissionError -q
collected 2 items
tests\test_learn\test_scanner.py ..                                      [100%]
2 passed in 1.86s

$ ruff check headroom/learn/plugins/claude.py tests/test_learn/test_scanner.py
All checks passed!
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.13, local dev checkout of headroom
on branch off upstream/main
- Exact command / steps: Simulated the issue by monkeypatching
`Path.exists` to raise `PermissionError` for the colliding candidate
`/home/marco/rocha`, then calling
`_decode_project_path("-home-marco-rocha-butterfly-sylphina")`
- Observed result: Before the fix the call propagates `PermissionError`
(crash, matching the reported traceback); after the fix it returns
without raising and the unreadable candidate is treated as non-existent.
Both regression tests pass.
- Not tested: End-to-end `headroom learn --apply` on a real Linux
multi-user box with an actually unreadable `/home/<prefix>` — reproduced
via the documented minimal logic instead.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 06:16:44 -07:00
Abhay Singh
eed80dd4ba
fix(learn/claude): don't abort the whole scan on a null message line (#2299)
## Description

A single Claude session-log line with an explicit `{"message": null}`
crashes the entire `headroom learn` run.

`ClaudeCodePlugin._scan_session` reads the message object in four
places:

```python
usage = d.get("message", {}).get("usage", {})   # assistant line
...
msg = d.get("message", {})                        # _extract_tool_uses
msg = d.get("message", {})                        # _extract_tool_results
msg = d.get("message", {})                        # _extract_user_events
```

`dict.get("message", {})` only substitutes `{}` for a **missing** key. A
present-but-null `{"type": "assistant", "message": null}` yields `None`,
and `None.get(...)` raises `AttributeError`.

The per-file guard only catches I/O errors:

```python
try:
    with open(jsonl_path, ...) as f:
        for line in f:
            ...
except (OSError, UnicodeDecodeError) as e:
    ...
    return None
```

so the `AttributeError` propagates out of `_scan_session`, past
`scan_project` (which has no try/except around the scan), and aborts the
whole `learn` invocation — every project, not just the one bad line. One
malformed line takes down the entire run.

## Fix

Coalesce the message with `or {}` at all four sites, so a null (or any
falsy) value collapses to `{}`:

```python
usage = (d.get("message") or {}).get("usage", {})
msg = d.get("message") or {}
```

The malformed line is now skipped and scanning continues; valid lines
are parsed exactly as before.

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

- `headroom/learn/plugins/claude.py`: coalesce `d.get("message")` with
`or {}` in `_scan_session` and the three `_extract_*` helpers.
- `tests/test_learn/test_subagent_scanning.py`: new test that a session
containing `{"message": null}` lines scans without crashing and still
parses the valid tool call.
- `CHANGELOG.md`: Bug Fixes entry.

## Testing

- [ ] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed

### Test Output

```text
$ uvx ruff@0.15.17 check headroom/learn/plugins/claude.py tests/test_learn/test_subagent_scanning.py
All checks passed!
$ uvx mypy@1.20.2 --ignore-missing-imports headroom/learn/plugins/claude.py
Success: no issues found in 1 source file
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12, `uvx ruff@0.15.17` / `uvx
mypy@1.20.2`. A full `pytest` OOM-kills this box (ML stack import), so I
reproduced the per-line handling with a dependency-free script and left
the full pytest to CI.
- Exact command / steps: ran an assistant line `{"message": null}` (plus
a real assistant line and a missing-message line) through the OLD
`get("message", {})` and NEW `get("message") or {}` logic.
- Observed result: OLD raises `AttributeError` on the null message; NEW
returns `0` for it and still counts `42` input tokens for the real line
and `0` for a missing-message line.
- Not tested: a full `learn` run over a real history containing such a
line; full local `pytest` deferred to CI (OOM).

## 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
- [ ] New and existing unit tests pass locally with my changes
- [x] I have updated the CHANGELOG.md if applicable

## Additional Notes

The "unit tests pass locally" box is unchecked because the full suite
imports the ML stack, which I can't run here. The new test reuses the
existing `ClaudeCodePlugin` scanner harness in
`tests/test_learn/test_subagent_scanning.py`, so it runs under the
normal CI pytest job; behaviour is additionally verified by the
standalone proof above.

---------

Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
2026-07-16 14:36:11 -07:00
Abhay Singh
c7b5a24b4f
fix(learn): don't shadow TIMEOUT/CONNECTION with the generic RUNTIME_ERROR (#2099)
## Description

`classify_error` (used by `headroom learn` to categorize failed tool
calls) miscategorizes timeouts and connection failures as generic
runtime errors.

The pattern list is checked in order, first match wins, and it puts the
generic catch-all *before* the specific categories:

```python
(re.compile(r"Traceback \(most recent|Exception:|Error:", re.I), ErrorCategory.RUNTIME_ERROR),
(re.compile(r"timed? ?out|TimeoutError|deadline exceeded", re.I), ErrorCategory.TIMEOUT),
...
(re.compile(r"ConnectionError|ConnectionRefused|ECONNREFUSED|network", re.I), ErrorCategory.CONNECTION_ERROR),
```

Every Python exception repr is `XxxError: ...` (or `Exception: ...`), so
the generic `Error:`/`Exception:` pattern matches first. A tool result
of `"TimeoutError: timed out after 30s"` is classified `RUNTIME_ERROR`
instead of `TIMEOUT`; `"ConnectionError: [Errno 111] Connection
refused"` is classified `RUNTIME_ERROR` instead of `CONNECTION_ERROR`.
The dedicated `TIMEOUT` and `CONNECTION_ERROR` categories — which
explicitly list `TimeoutError` and `ConnectionError` — are therefore
unreachable for the most common (colon-repr) message shape; they only
fire for tokenless phrasings like `deadline exceeded`. That mislabels
the learn digest's per-category error stats.

## Fix

Check the two specific categories (`TIMEOUT`, `CONNECTION_ERROR`) before
the generic `RUNTIME_ERROR` catch-all. A generic exception repr with no
timeout/connection token still classifies as `RUNTIME_ERROR`, so
existing behavior for those is unchanged (including the opencode
scanner's `"Error: command failed with exit code 1"` → `RUNTIME_ERROR`).

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

- `headroom/learn/_shared.py`: move the `TIMEOUT` and `CONNECTION_ERROR`
patterns above the generic `RUNTIME_ERROR` pattern, with a comment
explaining the ordering.
- `tests/test_learn/test_error_classification.py`: new tests asserting
`TimeoutError:`/`ConnectionError:` reprs classify specifically, a
generic `Error:` stays `RUNTIME_ERROR`, and non-error text is `UNKNOWN`.
- `CHANGELOG.md`: Bug Fixes entry.

## Testing

- [ ] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed

### Test Output

```text
$ uvx ruff@0.15.17 check headroom/learn/_shared.py tests/test_learn/test_error_classification.py
All checks passed!
$ python -m py_compile headroom/learn/_shared.py tests/test_learn/test_error_classification.py
OK
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12, `uvx ruff@0.15.17`. Importing
`headroom` pulls in the torch/transformers stack and a full `pytest`
gets OOM-killed on this box, so I verified the ordering with a
dependency-free script that replicates the pattern list under both the
old and new orderings, and left the full pytest to CI.
- Exact command / steps: classified `"ConnectionError: [Errno 111]
Connection refused"` and `"TimeoutError: timed out after 30s"` under the
old order (RUNTIME before TIMEOUT/CONNECTION) and the new order
(TIMEOUT/CONNECTION before RUNTIME), plus the opencode scanner's
`"Error: command failed with exit code 1"` as a regression guard.
- Observed result: old order classifies both as `RUNTIME_ERROR`; new
order classifies them as `CONNECTION_ERROR` and `TIMEOUT` respectively;
the guard string stays `RUNTIME_ERROR` under both orderings, so the
existing opencode scanner test is unaffected.
- Not tested: a full `headroom learn` digest run; full local `pytest`
deferred to CI (OOM, per above).

## 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
- [ ] New and existing unit tests pass locally with my changes
- [x] I have updated the CHANGELOG.md if applicable

## Additional Notes

The "unit tests pass locally" and "type checking" boxes are unchecked
because the full suite imports the ML stack, which I can't run in this
environment; the change is a reordering of two entries in a pure pattern
list, verified by the standalone proof (which also confirms the one
existing test that touches this path stays green) and the new regression
tests for CI.

Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
2026-07-13 10:50:54 -04:00
Fabien Culpo
d2170b1922
fix(learn): parse fenced JSON even with a prose preamble (#1988)
## Description

`_strip_fenced_json` only stripped a markdown fence when the string
*started with* ```` ``` ````. When the model prefixed prose before the
fence (e.g. `Here is the JSON:\n\n```json ...`) despite being told to
return JSON only, the guard was skipped and `json.loads` ran on the
prose, raising `JSONDecodeError`. The claude-cli streaming path surfaced
this as `returned unparseable output`, and `headroom learn` silently
discarded the LLM analysis, degrading to "No actionable patterns found".
This is the parsing-side cousin of the silent-degradation issue fixed in
#373.

Closes #1989. Related: #373.

## 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

- `headroom/learn/analyzer.py`: rewrote `_strip_fenced_json` to locate
the fenced block wherever it appears, then fall back to the whole text,
then to a first-`{` / last-`}` slice, only re-raising `JSONDecodeError`
if nothing parses as a JSON object. Preserves the prior "first opening /
last closing fence" behaviour and triple-backtick content inside the
payload. Fixes all three call sites (non-streaming CLI, claude-cli
streaming, litellm).
- `tests/test_learn/test_analyzer.py`: added regression cases to
`TestStripFencedJson` for preamble-before-fence, prose around a bare
object, and triple-backticks inside the payload.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`) — scoped to the changed
module (see Additional Notes)
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
$ pytest tests/test_learn/test_analyzer.py -q
........................................................................ [ 86%]
...........                                                              [100%]
83 passed, 1 warning in 2.18s

$ ruff check headroom/learn/analyzer.py tests/test_learn/test_analyzer.py
All checks passed!
$ ruff format --check headroom/learn/analyzer.py tests/test_learn/test_analyzer.py
2 files already formatted

$ mypy --ignore-missing-imports --follow-imports=silent headroom/learn/analyzer.py
Success: no issues found in 1 source file
```

## Real Behavior Proof

- Environment: macOS, Python 3.13.12, headroom-ai at this branch
(runtime deps from an installed 0.30.0 env).
- Exact command / steps: ran the old vs new `_strip_fenced_json` on the
exact failing model output (a prose preamble followed by a ```json
fence), then applied the fix over an installed 0.30.0 and re-ran the
previously failing `headroom learn --apply`. Input sample: `'The JSON is
my deliverable for this analysis task. Here it
is:\n\n```json\n{"context_file_rules": [], "memory_file_rules":
[]}\n```'`
- Observed result: OLD raised `JSONDecodeError: Expecting value: line 1
column 1 (char 0)`; NEW returned `{'context_file_rules': [],
'memory_file_rules': []}`. The real `headroom learn --apply` run that
had been failing with `returned unparseable output` then completed and
consumed the LLM analysis instead of dropping it. Full transcript:
  ```text
  OLD: JSONDecodeError -> Expecting value: line 1 column 1 (char 0)
  NEW: {'context_file_rules': [], 'memory_file_rules': []}
  ```
- Not tested: full end-to-end `headroom learn --apply` was not re-run
inside CI here (it shells out to a live `claude` CLI); the parser is
exercised deterministically by the added unit tests and the before/after
repro above.

## 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 (N/A —
updated the function docstring only; no external docs affected)
- [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
- [ ] I have updated the CHANGELOG.md if applicable (N/A — no CHANGELOG
entry convention observed for this fix; happy to add if maintainers
prefer)

## Additional Notes

- `mypy` was run against the changed module in isolation
(`--ignore-missing-imports --follow-imports=silent`) rather than the
full project, because I validated in an ad-hoc environment; the change
keeps the existing `-> dict` signature and annotations, so it is
type-neutral.
- Not addressed here (possible follow-up): the failure is swallowed as a
warning in `analyze()`, so users only see "No actionable patterns found"
with no signal the LLM pass produced nothing — the same
silent-degradation class as #373, on the parsing side.
2026-07-10 11:15:36 -07:00
Rod Boev
e3b45e402b
fix(learn): handle Windows UTF-8, drive-letter paths, and CLI shim fallback (#1895)
## Description

`headroom learn --verbosity` is broken on Windows in three related ways:

- Transcript/profile reads can use the platform default codec, so
non-ASCII content can raise `UnicodeDecodeError` and collapse learning
signals to empty output.
- `--project <path>` can miss real Claude project directories because
Windows profile junctions can raise `PermissionError` during directory
walks, and escaped Claude project folder names cannot always distinguish
`vibe-remote` from `vibe\remote`.
- `headroom learn --agent codex` can fail with `` `claude` not found in
PATH `` even when the npm-installed CLI exists, because Windows `.cmd`
shims require `PATHEXT` resolution.

Refs https://github.com/headroomlabs-ai/headroom/issues/1624 for the
Windows learn failures. The dashboard-hint UX and
third-party-provider-auth items in that issue are unrelated and out of
scope for this PR.

## 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

- `headroom/learn/verbosity.py`: read and write verbosity
transcripts/profiles with `encoding="utf-8"` so non-ASCII content works
regardless of the Windows locale codec.
- `headroom/learn/plugins/claude.py`: skip inaccessible siblings one
entry at a time during greedy project path decoding, so one Windows
junction no longer hides valid project directories.
- `headroom/learn/plugins/claude.py`: prefer a valid `cwd` found in
Claude session JSONL when discovering project paths, which resolves
ambiguous escaped folder names such as `vibe-remote` versus
`vibe\remote`.
- `headroom/learn/analyzer.py`: resolve Windows CLI shim paths through
`shutil.which()` after `FileNotFoundError`, then retry once for
streaming and non-streaming CLI calls.
- `CHANGELOG.md`: document the Windows learn fixes under `Unreleased`.

## Testing

- [x] Unit tests pass (`uv run pytest
tests/test_verbosity_learn.py::TestWindowsEncoding
tests/test_learn/test_scanner.py::TestGreedyPathDecode::test_permission_denied_sibling_does_not_abort_the_walk
tests/test_learn/test_analyzer.py::TestWindowsCliShimFallback
tests/test_learn/test_scanner.py::TestDecodeProjectPath::test_discover_project_prefers_session_cwd_over_ambiguous_folder_name
-q`)
- [x] Linting passes (`uv run ruff check
headroom/learn/plugins/claude.py tests/test_learn/test_scanner.py`)
- [x] Formatting passes (`uv run ruff format --check
headroom/learn/plugins/claude.py tests/test_learn/test_scanner.py`)
- [ ] Type checking passes (`uv run mypy headroom`) not run; no new
public type surface
- [x] New tests added for the Windows `cwd` disambiguation regression
- [x] Manual testing performed

### Test Output

```text
uv run ruff format headroom/learn/plugins/claude.py
1 file reformatted

uv run ruff check headroom/learn/plugins/claude.py tests/test_learn/test_scanner.py
All checks passed!

uv run ruff format --check headroom/learn/plugins/claude.py tests/test_learn/test_scanner.py
2 files already formatted

uv run pytest tests/test_verbosity_learn.py::TestWindowsEncoding tests/test_learn/test_scanner.py::TestGreedyPathDecode::test_permission_denied_sibling_does_not_abort_the_walk tests/test_learn/test_analyzer.py::TestWindowsCliShimFallback tests/test_learn/test_scanner.py::TestDecodeProjectPath::test_discover_project_prefers_session_cwd_over_ambiguous_folder_name -q
9 passed in 0.25s
```

CI on current head `c6dbac40` is green. A prior `test (1)` run hit an
unrelated timing-sensitive scheduler assertion; GitHub did not permit
direct rerun without admin rights, so the empty commit `c6dbac40`
retriggered CI and the shard passed.

## Real Behavior Proof

- Environment: Windows 11, Python 3.12, real filesystem for the
path-decoding reproduction.
- Exact command / steps: Ran `uv run pytest
tests/test_verbosity_learn.py::TestWindowsEncoding
tests/test_learn/test_scanner.py::TestGreedyPathDecode::test_permission_denied_sibling_does_not_abort_the_walk
tests/test_learn/test_analyzer.py::TestWindowsCliShimFallback
tests/test_learn/test_scanner.py::TestDecodeProjectPath::test_discover_project_prefers_session_cwd_over_ambiguous_folder_name
-q`, `uv run ruff check headroom/learn/plugins/claude.py
tests/test_learn/test_scanner.py`, and `uv run ruff format --check
headroom/learn/plugins/claude.py tests/test_learn/test_scanner.py`; the
path tests create real Windows-style project directories, inaccessible
siblings, ambiguous `vibe\remote` versus `vibe-remote` folders, and
Claude session JSONL with `cwd` pointing at the intended project.
- Observed result: The focused test command returned `9 passed in
0.25s`, Ruff check passed, and Ruff format check passed. The decoder
skips the inaccessible sibling and reaches `vibe-remote`; the
session-`cwd` test returns `vibe-remote` instead of trusting the
ambiguous escaped folder name; the UTF-8 tests round-trip non-ASCII
transcript/profile content under a non-UTF-8 Windows-style codec; the
CLI shim tests retry once through `shutil.which()` after
`FileNotFoundError`.
- Not tested: real npm-installed `claude`/`codex` CLI shims,
dashboard-hint UX, and third-party-provider auth.

## Review Readiness

- [x] I have performed a self-review
- [x] Retrospective review completed after opening; it found one missing
`cwd` disambiguation case, now fixed in this PR
- [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 the CHANGELOG.md if applicable

## Additional Notes

- The post-open retrospective review concluded this needed targeted
rework rather than only a retrospective sign-off.
- The `cwd` recovery commit is `ee720bb1`; `0905be7b` contains the
required formatter cleanup; current head `c6dbac40` is an empty CI-rerun
commit after GitHub denied direct rerun without admin rights.

---------

Co-authored-by: JD Davis <mxjerrett@gmail.com>
2026-07-09 12:49:38 -05:00
Parideboy
4f22cbb05c
fix(learn): decode Windows drive-style project dirs with dotted usernames (#1855)
## Description

Fixes #1849. On Windows, `headroom learn --all --apply` failed to write
recommendations for every project when the username contains a dot (e.g.
`pradipe.yoggi`), reporting `[WinError 161] The specified path is
invalid: '\\\Users\...'`.

Root cause: Claude Code encodes `C:\Users\first.last\proj` as
`C--Users-first-last-proj` — **no leading dash** (the path starts with
the drive letter), and `:` + `\` each collapse to `-`, producing a
double dash after the drive letter. Two defects followed:

1. `_decode_project_path()` required `escaped_name.startswith("-")` and
returned `None` for every real Windows encoding, so the greedy
filesystem-walking decoder (which correctly rejoins dotted components
like `first.last`) was unreachable.
2. The `discover_projects()` fallback blindly stripped the first
character (`entry.name[1:]`), turning `C--Users-...` into `--Users-...`,
whose dash→slash replacement yields the invalid
`\\\Users\first\last\proj` seen in the issue.

## Type of Change

- [x] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to not work as expected)
- [ ] Documentation update
- [ ] Refactoring (no functional changes)

## Changes Made

- `headroom/learn/plugins/claude.py`
- New `_decode_windows_path(drive, parts)` helper: drops empty split
tokens (so separators are never doubled), checks the literal path,
greedy-decodes from the drive root (so `Users` → `first.last` is
rejoined from the real filesystem via the existing
`_component_tokenizations` dot-split), and keeps the trust-`Users`
literal fallback.
- `_decode_project_path()` now matches both the real drive-style
encoding `C--Users-...` (no leading dash) and the legacy `-C-Users-...`
form via `^-?([A-Za-z])--?(.+)$`, routing both through the helper; POSIX
logic unchanged.
- `discover_projects()` fallback applies the same normalization instead
of stripping the first character, so nonexistent projects still get a
*valid* `C:\Users\...` path instead of `\\\Users\...`.
- `tests/test_learn/test_scanner.py`: three new tests — double-dash
encoding decodes without doubled separators; dotted username rejoined
via greedy decode on a real directory tree (Windows-only);
`discover_projects` fallback produces a valid path for a nonexistent
`C--Users-...` project.

## Testing

- [x] Existing tests pass locally
- [x] Added new tests covering the change

```
$ python -m pytest tests/test_learn -q
3 failed, 211 passed, 5 skipped in 7.22s
# The 3 failures (test_home_dir_username_stays_single_component,
# test_includes_project_info, test_double_write_replaces_not_appends) are
# pre-existing Windows-local failures, verified identical on a clean
# upstream/main checkout via git stash — none introduced by this change.

$ ruff check headroom/learn/plugins/claude.py tests/test_learn/test_scanner.py && ruff format --check ...
All checks passed!
$ mypy headroom --ignore-missing-imports
Success: no issues found
```

## Real Behavior Proof

- Environment: Windows 11 Pro, PowerShell, Python 3.14, headroom built
from this branch (Rust core built locally)
- Exact command / steps: `python -c "from headroom.learn.plugins.claude
import _decode_project_path as d;
print(d('G--Programmi-Aggiuntivi-headroom'));
print(d('C--Users-esiri-AppData-Local-Temp'))"` — decoding this
machine's own real `~/.claude/projects` directory names (which use the
drive-style encoding this PR fixes; note `Programmi Aggiuntivi` contains
a space, exercising the greedy multi-token rejoin just like a dotted
username)
- Observed result: `G:\Programmi Aggiuntivi\headroom` and
`C:\Users\esiri\AppData\Local\Temp` — both correct real paths. On
upstream/main the same call returns `None` for both, which is what
pushed `learn --all` into the mangling fallback.
- Not tested: an actual Active Directory `first.last` account end-to-end
(no such account available); covered instead by the Windows-only
greedy-decode test against a real `john.doe` directory tree and by the
space-in-path live decode above, which exercises the identical code
path.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 23:44:09 -05:00
Manmit Singh
c600e314b3
fix(learn): honor CLAUDE_CONFIG_DIR when locating Claude logs and memory (#1642)
## Description

`headroom learn` ignored `CLAUDE_CONFIG_DIR`.
`ClaudeCodePlugin.__init__` resolved the Claude config directory as
`~/.claude`, and the memory writer wrote the global `CLAUDE.md` to
`~/.claude/CLAUDE.md`. A user who relocates their Claude config with
that env var had `learn` scan the wrong directory and detect no
projects.

Other parts of the codebase already honor the override
(`subscription/client.py`, `subscription/session_tracking.py`,
`mcp_registry/claude.py`); the `learn` path was the outlier.

Closes #1630

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)

## Changes Made

- Add `claude_config_dir()` to `headroom/learn/_shared.py` — returns
`$CLAUDE_CONFIG_DIR` when set, else `~/.claude` (via `Path.home()`,
matching the existing override elsewhere).
- `ClaudeCodePlugin.__init__` now defaults `claude_dir` to
`claude_config_dir()` instead of a hardcoded `~/.claude` (an explicit
`claude_dir=` argument still wins).
- `ClaudeCodeWriter._resolve_context_path` writes the home-directory
global memory to `claude_config_dir() / "CLAUDE.md"` instead of
`~/.claude/CLAUDE.md`.

## 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/test_claude_config_dir.py tests/test_learn/test_writer.py -q
35 passed, 1 warning in 0.18s

$ ruff check headroom/learn/ tests/test_learn/test_claude_config_dir.py
All checks passed!

$ mypy headroom/learn/_shared.py headroom/learn/plugins/claude.py headroom/learn/writer.py
Success: no issues found in 3 source files
```

## Real Behavior Proof

- Environment: macOS (arm64), Python 3.14 venv, editable install of this
branch.
- Exact command / steps: ran `python -c "from
headroom.learn.plugins.claude import ClaudeCodePlugin;
print(ClaudeCodePlugin().projects_dir)"` with and without
`CLAUDE_CONFIG_DIR=/tmp/altclaude` set, then `pytest tests/test_learn/
tests/test_cli_learn.py`.
- Observed result: default prints `/Users/<me>/.claude/projects`; with
`CLAUDE_CONFIG_DIR=/tmp/altclaude` it prints `/tmp/altclaude/projects`
(before this change the second still printed `~/.claude/projects`). Test
suite: 226 passed, 3 skipped. New regression tests cover the plugin scan
dir, explicit-arg precedence, and the writer's home-memory path.
- Not tested: end-to-end `headroom learn` against a real relocated log
tree with live Claude Code transcripts — verified at the plugin/writer
resolution layer plus the existing scanner suite.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## Additional Notes

The identical hardcode also exists at `headroom/cli/mcp.py:21`
(`CLAUDE_CONFIG_DIR = Path.home() / ".claude"`), but that is a separate
command outside this issue's scope, so I left it for a follow-up to keep
this PR to one issue.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-01 23:22:16 -05:00
T. P.
91cd2102d7
feat: add first-class OpenCode support (wrap, learn, mcp install) (#559)
## Summary

Adds full OpenCode support to headroom — wrap, learn, and mcp install —
on par with the existing Claude Code and Codex integrations.

## Changes

### Provider slice (`headroom/providers/opencode/`)
- **runtime.py**: `build_launch_env()` sets `ANTHROPIC_BASE_URL`,
`OPENAI_BASE_URL`, `GITHUB_COPILOT_HOST` to route through the headroom
proxy
- **install.py**: `apply_provider_scope()` patches
`~/.config/opencode/opencode.json` with `baseURL` for github-copilot,
anthropic, and openai providers

### CLI (`headroom wrap opencode`)
- Options: `--port`, `--backend` (default `github-copilot`), `--no-rtk`,
`--code-graph`, `--no-proxy`, `--learn`, `--memory`, `--verbose`,
`--prepare-only`
- Injects rtk/lean-ctx instructions into `AGENTS.md`
- Token check for `GITHUB_TOKEN` / `GITHUB_COPILOT_*` env vars

### Learn plugin (`headroom/learn/plugins/opencode.py`)
- Reads `~/.local/share/opencode/opencode.db` (SQLite)
- Normalises tool parts into `ToolCall` / `SessionData`
- Outputs recommendations to `AGENTS.md` via `CodexWriter`

### MCP registrar (`headroom/mcp_registry/opencode.py`)
- Reads/writes `~/.config/opencode/opencode.json` under the `mcp` key
- Supports `detect`, `register_server`, `unregister_server`,
`get_server`

### Registration glue
- `ToolTarget.OPENCODE` in `install/models.py`
- `opencode_config_path()` in `install/paths.py`
- Registered in `providers/install_registry.py` and
`mcp_registry/install.py`

## Test plan

- `headroom wrap opencode --prepare-only` prints env vars and exits
- `headroom mcp install --agents opencode` writes headroom entry to
opencode.json
- `headroom learn opencode` mines sessions and appends to AGENTS.md


<!-- headroom-maintainer-template-completion:start -->

## Description

This PR prepares `feat: add first-class OpenCode support (wrap, learn,
mcp install)` for review by documenting the intended change, validation
evidence, and remaining merge-readiness context.

Linked issues: None declared.

## Type of Change

- [ ] Bug fix
- [x] New feature
- [ ] Documentation
- [ ] Refactor
- [ ] Tests only

## Changes Made

- Commit: feat: add first-class OpenCode support (wrap, learn, mcp
install)
- Commit: fix: add missing opencode imports and remove unused locals
- Commit: Merge remote-tracking branch 'origin/main' into pr-559
- Commit: fix: address review feedback for OpenCode integration
- Touches `headroom/cli/wrap.py`
- Touches `headroom/install/models.py`
- Touches `headroom/install/paths.py`
- Touches `headroom/learn/plugins/opencode.py`
- Touches `headroom/mcp_registry/__init__.py`
- Touches `headroom/mcp_registry/install.py`

## Testing

- [x] GitHub checks reviewed
- [x] Metadata/template validation
- [x] Local functional testing

### Test Output

```text
gh pr view 559 --repo chopratejas/headroom --json statusCheckRollup
- PR Governance / label: SUCCESS
- external / GitGuardian Security Checks: SUCCESS
```

## Real Behavior Proof

- Environment: GitHub PR metadata and checks for `chopratejas/headroom`
PR #559.
- Exact command / steps: Reviewed PR title, commits, changed files,
linked issues, labels, and check rollup; appended this maintainer
template completion block without replacing the author's original
description.
- Observed result: PR body now contains all required governance
sections, checked readiness fields, and a non-placeholder validation
evidence block.
- Not tested: This pass updated PR metadata only; code validation
remains represented by the linked GitHub checks and any author-provided
evidence above.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

<!-- headroom-maintainer-template-completion:end -->

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-06-25 13:38:58 -05:00
Purva Kandalgaonkar
14e8dc4c84
feat(learn): weight loops in Headroom Learn + RTK-loop eval (#1160)
## Description

`headroom learn` ranked recommendations by a single LLM-guessed
`estimated_tokens_saved` with a flat hardcoded `confidence`, and had
**no notion of a loop**. So (1) RTK re-fetch loops were invisible - RTK
truncates a command's output, the agent re-runs larger-limit variants,
those calls *succeed* (`is_error=False`), and `analyze()` even
early-returned when a session had no failures and no events - and (2)
even when surfaced, a loop ranked no higher than a one-off mistake. This
adds loop-aware weighting plus the eval that reproduces an RTK loop,
runs it through Learn, and checks the guardrail prevents re-triggering.

Closes #1159

## 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

- New `headroom/learn/loops.py`: `detect_loops()` (canonical signature
collapses RTK pagination/limit variants; classifies error vs rtk-refetch
loops; **measured** wasted tokens), `format_loops_for_digest()`,
`apply_loop_weighting()`.
- `analyzer.py`: detect loops up front (fixes the no-failure
early-return), lead the digest with them, prioritize loops in the system
prompt, re-sort after weighting.
- `models.py`: `Recommendation.is_loop_guardrail` / `loop_occurrences`.
- `benchmarks/rtk_loop_learn_eval.py` + `headroom/learn/fixtures.py`:
the two-phase RTK-loop eval and its session fixtures.
- Tests, `docs/rtk-loop-weighting.md`, CHANGELOG entry.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`) - not run (mypy not in my
minimal env; see Not tested)
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
$ python -m pytest tests/test_learn/ -q
190 passed, 3 skipped, 1 warning in 5.85s
$ ruff check <changed files>
All checks passed!
```

## Real Behavior Proof

- Environment: macOS (Darwin 25.0), Python 3.10.18, fresh venv (`pip
install -e` minus the optional `hnswlib`/proxy extras, which are
unrelated to `learn`); real LLM via the analyzer's claude CLI backend
(`HEADROOM_LEARN_CLI=claude`, claude-cli 2.1.158) — no API key used.
- Exact command / steps: `HEADROOM_LEARN_CLI=claude python -c "from
benchmarks.rtk_loop_learn_eval import run_eval;
c=run_eval(use_real_llm=True); print(c.render())"`
- Observed result: the analyzer shelled out to a real model and produced
the "Commands" guardrail quoted below, naming the looping command. The
digest reports the measured 5,005-token waste and asks the model to rank
loops first, so the model emitted that figure; in this run the guardrail
ranked **#1** and the scorecard was all-PASS (below). Caveat — real-mode
is run-dependent: the rule's wording, and whether the post-hoc
`apply_loop_weighting` fuzzy match fires, vary across runs (in one run
it did not tag the rule). The **deterministic CI eval** (stub LLM) is
the stable, reproducible artifact; this real run corroborates it.
- Not tested: the analyzer's API-key path (ANTHROPIC/OPENAI/GEMINI) —
exercised the equivalent claude CLI backend instead; `mypy`; a live
agent *obeying* the written rule end-to-end (Phase 2 is a non-recurrence
check, not a live agent — called out in the doc).

Real model output from this run, ranked #1 at the measured 5,005-token
weight:

> **Commands** — When grepping logs (or any large file), never loop with
increasing `| head -N` limits — tool output is capped at ~4 KB
regardless of N, so repeated attempts return identical bytes. Instead:
redirect to a temp file (`grep ... > /tmp/out.txt`) then read it, or use
`grep -c` first…

```text
[PASS] loop_detected          (1 loop(s), ~5,005 tok wasted)
[PASS] guardrail_produced
[PASS] ranked_first
[PASS] names_command
[PASS] prescribes_fix
[PASS] weight_reflects_waste
[PASS] guardrail_holds
RESULT: PASS
```

(One real-mode run via the claude CLI backend. The deterministic
`pytest` eval above is the stable artifact; see the run-dependence
caveat under Observed result.)

The real run also caught an over-brittle check: an earlier
`names_command` required the literal "TimeoutError"; the real model
wrote a *more general* rule (grep + `head -N`) without it, so I fixed
the check to verify the looping **command** is named, not an incidental
literal.

## 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 the CHANGELOG.md if applicable

## Additional Notes

- No new dependencies. No network, no user/assistant content dropped —
operates on already-captured session digests.
- Kept as one logical change. mypy not run locally (minimal env); happy
to address anything CI's mypy flags.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: JD Davis <mxjerrett@gmail.com>
2026-06-22 18:49:08 -05:00
Focused Instability
ced75e4718
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 15:05:06 -05:00
Rudimar Ronsoni
b4571cc346
feat: headroom wrap opencode / unwrap opencode CLI (#1105)
## Summary

This PR implements transparent `headroom wrap opencode` support without
asking users to edit OpenCode provider URLs, choose an extra CLI flag,
or maintain a static provider list.

The wrapper now lives at the runtime transport boundary: OpenCode keeps
its user/provider config, while Headroom intercepts outbound provider
traffic in-process and routes it through the local Headroom proxy.

## What changed

### Transparent OpenCode wrapping

- `headroom wrap opencode` injects the `headroom-opencode` plugin
through `OPENCODE_CONFIG_CONTENT`.
- Existing OpenCode provider URLs are preserved. We do not rewrite user
config URLs to point at Headroom.
- Existing `OPENAI_BASE_URL` and `ANTHROPIC_BASE_URL` env vars are
preserved.
- Local OpenCode traffic, localhost traffic, and Headroom proxy traffic
bypass the shim to avoid loops.

### Runtime transport interception

- Added an OpenCode plugin transport shim that wraps:
  - `globalThis.fetch`
  - `http.request` / `http.get`
  - `https.request` / `https.get`
- External provider calls are routed to the local Headroom proxy.
- The original upstream origin is passed through `x-headroom-base-url`,
so the proxy can forward to the real provider without changing OpenCode
config.
- External `http2.connect` is blocked loudly instead of allowing direct
provider traffic to leak outside Headroom.

### Live provider additions

Provider coverage is no longer based on a static config scan. Because
routing happens at outbound request time, providers added mid-session
are routed through Headroom automatically as long as they use the
covered Node transport paths.

### Subagent and child-process coverage

- The parent OpenCode plugin sets a packaged Node preload shim through
`NODE_OPTIONS=--import=.../hook-shim/handler.js`.
- The transport shim patches `child_process.spawn`, `exec`, `execFile`,
and `fork` so child Node processes receive the Headroom preload even
when OpenCode passes a custom `env`.
- The child-process shim fails closed if it loads without
`HEADROOM_OPENCODE_TRANSPORT_PROXY_URL`.
- This closes the subagent leak path where a child Node process could
otherwise start without Headroom transport interception.

## Why this goes beyond PR #1089

PR #1089 improves OpenCode provider registration, but it still focuses
on provider config shape. This PR moves the enforcement boundary to
runtime transport interception.

This PR goes further because:

- No provider URL rewriting is required.
- New providers added mid-session are covered automatically.
- Subagents and child Node processes inherit the Headroom transport
shim.
- Direct external HTTP/2 paths fail loudly instead of leaking.
- The wrap remains transparent to the user's OpenCode provider config.
- The wrapper is fail-closed for unsupported child-process preload
state.

## Additional robustness fixes

While validating the change in Docker, the full Python suite exposed
unrelated Linux/container robustness issues. These are fixed in this PR
so the suite is green:

- Binary cache handling now treats cache paths under a non-writable
existing parent as unavailable, including when tests run as root in
Docker.
- `release_version.py` honors `MANUAL_VER` before git calls so direct
script execution works outside a `.git` checkout.
- Test logger isolation now resets relevant Headroom child loggers so
proxy logging setup cannot poison later `caplog` tests.
- The scanner missing-path test now uses a guaranteed missing `tmp_path`
child instead of relying on `/nonexistent/path`.

## Validation

All implementation validation was run inside Docker.

- Full Python suite from a fresh Docker copy: `6605 passed, 523
skipped`.
- Ruff on changed Python/OpenCode paths: passed.
- OpenCode plugin typecheck: passed.
- OpenCode plugin tests: `9 passed`.
- OpenCode plugin build: passed.
- Hook shim preload smoke test: passed.

## Notes

This PR intentionally does not add a CLI option. `headroom wrap
opencode` means full wrap. Either Headroom wraps OpenCode transparently,
or the path fails loudly instead of silently leaking provider traffic.

---------

Co-authored-by: Rudimar Ronsoni <6081613+rudironsoni@users.noreply.github.com>
2026-06-22 11:07:12 -05:00
jichaowang02-lang
6129808462
Fix headroom learn crashing/no-op on Windows from missing UTF-8 encoding (#1239)
## Description

Fixes #1202. On a Windows (cp1252) locale, `headroom learn` cannot
complete a
run: the whole pipeline opens transcript files and pipes analyzer
prompts
without `encoding="utf-8"`, so any non-ASCII byte (em-dashes, arrows —
ubiquitous
in code and prose) breaks it. Same bug class already fixed for `headroom
wrap`
(#65, #1126) and the dashboard (#533), never swept through `learn`.

Three independent failure points, each hidden behind the previous:

1. **Reading transcripts** — six bare `open()` calls in the learn
plugins. The
**Codex** JSONL scanner caught only `OSError`, so a `UnicodeDecodeError`
propagated and **aborted the whole cross-agent run**; the **Claude**
scanner
caught it and **silently dropped the session**. `analyzer.py` also read
the
   user's own CLAUDE.md/MEMORY.md with no encoding.
2. **Analyzer subprocess** — `subprocess.run`/`Popen(..., text=True)`
with no
encoding raised `UnicodeEncodeError` on the piped prompt; it was
swallowed,
   so the run produced **0 recommendations** with no obvious failure.
3. **`--apply` merge** — `writer.py` read the existing context file with
strict
   `encoding="utf-8"`, which aborts on a single stray legacy byte.

## 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

- `learn/plugins/{claude,codex,gemini}.py`: add `encoding="utf-8",
errors="replace"`
  to the six transcript `open()` calls.
- `learn/analyzer.py`: same on the `read_text` of the user's context
files and on
  both analyzer subprocess calls (`subprocess.run` and `Popen`).
- `learn/writer.py`: add `_read_text_tolerant` — decode the
to-be-rewritten
context file as UTF-8, falling back to UTF-8-with-replacement on a stray
byte
(a whole-file cp1252 fallback is wrong: it mojibakes genuine UTF-8
em-dashes);
  the subsequent `write_text(encoding="utf-8")` self-heals the file.
- `cli/learn.py`: wrap `plugin.scan_project` so one unreadable
agent/project is
  skipped with a warning instead of aborting the whole run.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check`)
- [ ] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
$ pytest tests/test_learn/test_writer.py tests/test_learn/test_plugin_encoding.py -q
22 passed

$ ruff check headroom/learn/plugins/*.py headroom/learn/analyzer.py \
    headroom/learn/writer.py headroom/cli/learn.py tests/test_learn/test_*.py
All checks passed!
```

New tests are **red on the old code, green with the fix**:
- `test_plugin_encoding.py` — a transcript with a stray `0x9d` byte
(undefined in
cp1252 *and* an invalid UTF-8 start byte, so a bare `open()` fails on
any
locale): the Codex scanner no longer raises, the Claude scanner now
recovers
  the session instead of dropping it.
- `test_writer.py::TestEncodingResilience` — `_read_text_tolerant`
preserves
valid UTF-8 (no mojibake) and `--apply` merges over a file with a stray
byte.

## Real Behavior Proof

- Environment: Windows 11, Python 3.10, against the real learn
plugins/writer
(no live LLM backend; the decode failures occur before any backend
call).
- Exact command / steps: write a Claude transcript and a Codex rollout
JSONL
containing a valid em-dash/arrow line plus a stray `0x9d` byte, then
call
`ClaudeCodePlugin._scan_session` / `CodexPlugin._scan_jsonl_session`;
for the
writer, `write_bytes` an `AGENTS.md` with a stray `0x97` and run
`_merge_into_file`.
- Observed result: **before** the fix →
`CodexPlugin._scan_jsonl_session` raises
`UnicodeDecodeError: 'utf-8' codec can't decode byte 0x9d` (aborts the
run) and
`ClaudeCodePlugin._scan_session` returns `None` (session dropped);
**after** →
Codex completes, Claude returns the `SessionData` (`total_input_tokens
== 5`),
  and `_merge_into_file` keeps `Notes — existing` with no mojibake.
- Not tested: a full end-to-end `headroom learn --apply` against live
agent
histories + a real LLM backend (verified at the plugin/writer level,
which is
  where the decode failures live).

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review
2026-06-21 10:37:37 -07:00
Tejas Chopra
0ddd4ed9e9
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
Shengbo_Wang
2d3701b59e
fix(learn): decode directory names with spaces in Windows project paths (#997) (#1027)
## Description

`headroom learn --apply` crashes with `FileNotFoundError` when the
project lives in a Windows directory whose name contains spaces (e.g.
`C:\Users\user\Desktop\Claude Code Projects`).

Claude Code encodes that path as
`-C-Users-user-Desktop-Claude-Code-Projects`, using `-` for both path
separators *and* spaces. The greedy path decoder walks the real
filesystem to reconstruct the original components, but
`_component_tokenizations()` never tried splitting on spaces — so it
couldn't match `Claude Code Projects` against tokens `["Claude", "Code",
"Projects"]`.

Closes #997

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)

## Changes Made

- Added `" "` (space) to the explicit separator list in
`_component_tokenizations()`
- Updated the catch-all regex from `[-._]` to `[-.\s_]` so the combined
split also covers whitespace
- Same change in the hidden-component (dotfile) branch

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] New tests added for new functionality

### Test Output

```text
tests/test_learn/test_scanner.py::TestGreedyPathDecode::test_single_space_in_dirname PASSED
tests/test_learn/test_scanner.py::TestGreedyPathDecode::test_multiple_spaces_in_dirname PASSED
tests/test_learn/test_scanner.py::TestGreedyPathDecode::test_space_nested_path PASSED
tests/test_learn/test_scanner.py::TestDecodeProjectPath::test_windows_path_with_spaces_decoded_via_greedy PASSED

4 passed in 0.64s
```

## Real Behavior Proof

- Environment: Windows 11 Home 10.0.26200, Python 3.10.18
- Exact command / steps: Ran `python -m pytest
tests/test_learn/test_scanner.py -v` on Windows after applying the fix.
Also verified `_component_tokenizations("Claude Code Projects")` returns
`[['Claude Code Projects'], ['Claude', 'Code', 'Projects']]`. The
integration test creates a real temp directory with spaces and asserts
`_decode_project_path()` resolves it correctly.
- Observed result: All 4 new tests pass on Windows. All 34 scanner tests
pass. Ruff check clean.
- Not tested: No manual `headroom learn --apply` end-to-end run, but the
integration test exercises the same `_decode_project_path` code path
with a real temp directory on disk.

## 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] 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

## Additional Notes

The fix follows the exact same pattern used for underscores (issue #159)
and dots (issue #47) — extending the separator list. Spaces are the last
common character that Claude Code flattens to `-` but the decoder didn't
know about.
2026-06-15 23:29:53 -05:00
gglucass
9bff5752bb
fix(learn): claude-cli streams output with idle timeout (#373)
## Description

`headroom learn` with the claude-cli backend used `subprocess.run` with
a hard 120s wall-clock cap and no liveness signal. A successful long
analysis and a hung connection looked identical — exit 0 with "0
recommendations" was the only user-visible signal when the LLM call
timed out, which silently hides genuine learnings.

This PR makes the CLI backend timeout-aware, with progress detection for
claude-cli and configurable wall-clock caps for every backend.

Fixes #(issue number)

## 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

- **Streaming claude-cli with idle timeout**: invoke `claude -p
--output-format stream-json --verbose` and run a watchdog loop that
drains stdout/stderr via reader threads. Each stream-json event resets
an idle deadline. Kill the process if no output for
`HEADROOM_LEARN_CLI_IDLE_TIMEOUT_SECS` (default 60s) or if total elapsed
exceeds `HEADROOM_LEARN_CLI_TIMEOUT_SECS` (default 300s, was 120s). The
final `type:"result"` event carries the assistant response, which is
then parsed as JSON. Reader threads (rather than `select`) are used so
the watchdog works on Windows where `select` does not support pipe
handles.
- **Bumped default `_CLI_TIMEOUT` from 120s to 300s** as the hard cap
for all CLI backends. The previous 120s was too tight for large digests
on slower networks.
- **Env-var overrides** via new helper `_resolve_timeout_secs(env_var,
default)`:
- `HEADROOM_LEARN_CLI_TIMEOUT_SECS` — hard wall-clock cap (all CLI
backends)
- `HEADROOM_LEARN_CLI_IDLE_TIMEOUT_SECS` — idle cap (streaming
claude-cli only)
- Non-positive or non-integer values log a warning and fall back to
defaults, so a typo can't disable the timeout.
- **gemini-cli and codex-cli** keep `subprocess.run(timeout=hard_cap)`
since they do not emit progress events. They benefit from the bumped
default and the env-var override.
- **CHANGELOG.md** updated under `[Unreleased]` → `### Fixed`.

## 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 (existing repro: 16k-call digest that
previously timed out at 120s)

New test coverage in `tests/test_learn/test_analyzer.py`:

- `test_claude_cli_streams_and_parses_result_event` — happy path, fake
Popen yields system/assistant/result events
- `test_claude_cli_parses_fenced_result` — markdown fences in the result
event still parse
- `test_claude_cli_idle_timeout_kills_hang` —
`HEADROOM_LEARN_CLI_IDLE_TIMEOUT_SECS=1` + a hanging stdout iterator
triggers the idle watchdog
- `test_claude_cli_hard_cap_kills_continuous_chatter` — continuous
events with a low hard cap fire the wall-clock kill (proves idle reset
alone can't keep a runaway alive)
- `test_claude_cli_missing_result_event_raises` — graceful failure when
no `result` event is emitted
- `test_claude_cli_nonzero_exit_raises` /
`test_claude_cli_unparseable_result_raises_with_context` /
`test_claude_cli_not_installed_raises` — error paths
- Parallel codex-cli error coverage (timeout-honors-env-override
included) so the wall-clock path is exercised
- `TestResolveTimeoutSecs` — unset / empty / non-integer / non-positive
/ valid override

## Test Output

```
$ uv run pytest tests/test_learn/test_analyzer.py
============================== 67 passed in 2.14s ==============================

$ uv run ruff check headroom/learn/analyzer.py tests/test_learn/test_analyzer.py
All checks passed!

$ uv run ruff format --check headroom/learn/analyzer.py tests/test_learn/test_analyzer.py
2 files already formatted

$ uv run mypy headroom/learn/analyzer.py
Success: no issues found in 1 source file
```

## 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

## Additional Notes

- The contract assumed for claude-cli stream-json output is: each line
is a JSON object with a `type` field; the final event has
`type:"result"` with a string `result` field carrying the assistant
text. This matches the documented Anthropic CLI behavior. If the
contract changes upstream, `_call_claude_cli_streaming` raises a clear
"did not emit a final \`result\` event" error rather than silently
succeeding.
- Backwards-compatible for users without env-var configuration: behavior
just becomes "longer hard cap, plus idle watchdog for claude-cli",
neither of which can falsely succeed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-11 11:55:19 -05:00
Evan Alferez
d7973665f4 fix(learn): finish gemini-flash-latest default model sweep (#532)
Google deprecated gemini/gemini-2.0-flash; headroom learn silently fails
when GEMINI_API_KEY is set. PR #532 updated the default in analyzer.py
but left stale references in the CLI help text and unit test assertion.
2026-06-03 08:32:43 +09:00
MrAshRhodes
92d71b8866 test(learn): ruff-format scanner test and broaden dotted-home coverage
PR #506 merged with the test file left un-formatted, so 'ruff format --check' now fails on main (the 'test (3.12)' CI job). Apply 'ruff format' to tests/test_learn/test_scanner.py to restore a green format check.

Also replace the skip-only Unix username test with test_home_dir_username_stays_single_component, which roots a throwaway project at the real home and decodes its flattened name. It exercises the Users/home branch on both macOS (/Users) and Linux CI (/home/runner) instead of skipping off /Users, restoring patch coverage of the decode fix.
2026-05-30 13:49:23 +02:00
MrAshRhodes
491a8b3a1b fix(learn): decode Unix home dirs whose username contains '.', '-' or '_'
Claude Code escapes project paths by flattening '/', '.', '-' and '_' to
'-', so /Users/first.last/proj is stored as -Users-first-last-proj. The
decoder consumed only the first token after "Users"/"home" as the home
directory and walked from /Users/first, which does not exist, so it bailed
out. Callers then fell back to the literal "/Users/first/last", and
'headroom learn --apply' failed with PermissionError: '/Users/first' when
writing recommendations.

Start the greedy decode at the mount root and pass the remaining tokens so
the multi-token home component is reconstructed by tokenisation, with a
fallback to the legacy single-token behaviour. Adds the Unix counterpart of
test_windows_username_with_dot_stays_single_component.
2026-05-29 10:13:37 +02:00
Tejas Chopra
5ceca13c65 fix: harden learn path handling across platforms 2026-05-09 15:45:26 -07:00
Kayzo
0264e03d33 fix(testing): stabilize 3.12 suite and fingerprints 2026-04-28 21:35:32 +00:00
Garm
35073ccb23 style(learn): ruff format test_analyzer.py
Line-length wrapping only. No behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 13:24:46 +02:00
Garm
6d2aba8741 fix(learn): show prior patterns block to LLM to prevent dangling refs
When `headroom learn` re-surfaced a section heading that already existed
in CLAUDE.md / MEMORY.md, the writer replaced that section wholesale —
but the LLM never saw the prior block, so it emitted condensed bullets
like "X is *also* large — same rule as Y, Z" assuming Y and Z would
remain siblings. After replacement, Y and Z were gone and the "also"
dangled.

This threads the project's current `<!-- headroom:learn -->` block (from
both CLAUDE.md and MEMORY.md) into the digest as a "Prior Learned
Patterns" section, and extends the system prompt to make the re-emission
contract explicit: re-stating a section replaces it wholesale, so the
LLM must copy forward prior bullets it still agrees with. Prior sections
the LLM omits entirely are still carried forward by the writer (#231
behavior preserved as a safety net).

Changes:
- New `extract_marker_block(file_content)` helper in `learn.writer` that
  returns the raw marker block (delimiters included) or None.
- New `_build_prior_patterns_section(project)` in `learn.analyzer` reads
  `project.context_file` and `project.memory_file` via the new helper
  and formats a labeled section ahead of the per-session event stream.
- `_build_digest` emits the prior-patterns section when present; char
  budget accounting unchanged (prior blocks are small).
- `_SYSTEM_PROMPT` gains a "Prior Learned Patterns" rule block telling
  the LLM how to integrate prior bullets (preserve / revise / drop-only-
  if-contradicted) and warning against unresolved cross-references.
- Tests: 6 new `TestPriorPatternsInjection` cases (present/absent files,
  no-marker-block, both-files, end-to-end via mocked `_call_llm`); 4 new
  `TestExtractMarkerBlock` cases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 12:59:02 +02:00
Garm
aad799d76e test(learn): cover _parse_prior_recommendations edge cases
Closes the codecov gap flagged on PR 232 (88.89% → near 100% on the patch):

- A file with no marker block returns no prior recommendations.
- A marker block with nothing between the markers yields an empty list
  (the re.split fast-path with zero sections).
- A stray `### ` with no heading text inside the block is silently
  skipped (the `if not heading: continue` branch, previously
  unexercised in tests) — a real section after it still parses cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 20:13:49 +02:00
Garm
72ae0a9e03 chore: apply ruff format + add CHANGELOG entry 2026-04-22 11:28:11 +02:00
Garm
0123e49939 fix(learn): preserve prior recommendations across runs (#231)
`headroom learn` built the marker block from only the current run's
recommendations and wholesale-replaced any prior block via
`_MARKER_PATTERN.sub`. Sections learned weeks earlier that didn't
re-surface in a later run were silently dropped.

Fix: in `_merge_into_file`, parse recommendations out of the prior
block and union them with the new run's recommendations. Sections
re-surfaced by the new run take precedence (latest analysis wins);
sections not re-surfaced are carried forward so learnings accumulate
instead of getting clobbered.

To fully rebuild the block, delete it manually and re-run.

Tests: existing wholesale-replace test rewritten as a carry-forward
assertion. Added tests for same-section override, MEMORY.md
carry-forward, and round-trip of sections without a tokens annotation.

Closes #231
2026-04-22 10:22:36 +02:00
chopratejas
7c91fe1e4b Fix headroom learn failing on project paths with underscores (#159)
_component_tokenizations only split on `-` and `.`, so directory names
like `my_project` could never be reconstructed from the dash-encoded
slug. Add `_` as a separator so the greedy decoder matches snake_case
directory names correctly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 15:59:49 -07:00
Gyeonghun Park
c3cf022886 fix(learn): handle FileNotFoundError when CLI tool is not installed
When --model codex-cli is used but codex is not in PATH,
subprocess.run raises FileNotFoundError. Catch it and raise
a clear RuntimeError with guidance.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 22:11:19 +09:00
Gyeonghun Park
e98c975153 feat(learn): add CLI-based LLM backends for keyless headroom learn
Allow `headroom learn` to use locally installed coding agent CLIs
(claude, gemini, codex) as LLM backends, so subscription users
without raw API keys can run failure analysis.

Priority: --model flag > API key > HEADROOM_LEARN_CLI env var > auto-detect

- Pass prompts via stdin to avoid ARG_MAX limits
- Handle TimeoutExpired, truncate stderr, enrich JSONDecodeError
- Add 31 new tests (48 total), all passing
- Update docs/learn.md with CLI backend documentation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 22:02:06 +09:00
chopratejas
446872ed53 Plugin architecture for headroom learn + live traffic flush
Refactor headroom learn into a plugin architecture where each coding
agent (Claude Code, Codex, Gemini CLI) is a self-contained plugin
with scanner, writer, and detection logic. External plugins can
register via the headroom.learn_plugin entry point.

- Add LearnPlugin ABC (base.py) and plugin registry (registry.py)
- Move scanners from monolithic scanner.py into plugins/ directory
- Extract shared error classification and tool name map (_shared.py)
- Add GeminiScanner for Google Gemini CLI session parsing
- CLI uses dynamic agent detection via registry (no hardcoded choices)
- All existing imports preserved via backwards-compat re-exports
- Wire agent_type through wrap → proxy → TrafficLearner
- Flush learned patterns to correct .md file at proxy shutdown
- Fix shutdown queue drain bug (patterns were lost on exit)
- 97 tests pass (84 existing + 13 new registry/plugin tests)
2026-04-09 20:30:20 -07:00
chopratejas
c7731b1d21 Fix Windows drive letter path decoding in headroom learn (fixes #69)
_decode_project_path now detects single-letter first component as a
Windows drive letter: -C-MQ2-macros → C:\MQ2\macros instead of
/C/MQ2/macros (which becomes \\C\MQ2\macros on Windows).

- Add Windows drive detection before Unix path attempts
- Fix fallback path construction for Windows patterns
- Add Linux /home/ support in greedy decoder
- Add 2 tests for Windows drive letter patterns
2026-03-30 09:08:21 -07:00
chopratejas
f6a6c609ad Fix ruff format for litellm, wrap, test_scanner 2026-03-24 16:02:22 -07:00
Garm
47d0e4d5a8 Fix tests 2026-03-23 13:24:02 +01:00
Garm
af448a568f . 2026-03-23 13:21:06 +01:00
Garm
cb1c9aec7d Add path "." compatibility for headroom learn 2026-03-23 13:13:54 +01:00
Garm
a24daf35ab . 2026-03-20 18:45:28 +01:00
Tejas Chopra
da481a359b fix(learn): pass explicit model in tests to avoid API key requirement
SessionAnalyzer() without a model calls _detect_default_model() which
raises when no API keys are set (e.g., in CI). Pass model="test-model"
in the three tests that mock _call_llm.
2026-03-07 15:10:25 -08:00
Tejas Chopra
4d14012c2f feat: add headroom perf CLI and rewrite headroom learn to use LLM analysis
Proxy performance logging (`headroom perf`):
- Add always-on RotatingFileHandler to ~/.headroom/logs/proxy.log (10MB x 5 backups)
- Replace scattered log lines with structured PERF lines containing model, msgs,
  tok_before/after/saved, cache_read/write/hit_pct, opt_ms, and transforms
- Emit PERF lines from all three response paths (streaming Anthropic, non-streaming
  Anthropic, Bedrock streaming)
- Add `headroom perf` CLI that parses proxy logs and reports token savings, cache
  hit rates, prefix stability, transform effectiveness, routing breakdown, TOIN
  status, and actionable recommendations
- Support --hours and --raw flags for time filtering and raw record output

Learn module rewrite (LLM-based analysis):
- Replace all regex/heuristic analyzers with a single LLM call via LiteLLM
- New SessionAnalyzer builds compact digests and sends to any of 100+ models
- Auto-detect best model from API keys (Anthropic → OpenAI → Gemini)
- Add --model flag for explicit model selection
- Enrich scanner with SessionEvent (user messages, interruptions, subagent summaries),
  token usage tracking, and timestamps
- Simplify models: remove EnvironmentFact, StructureNote, Correction, CommandPattern,
  RetryPattern, AnalysisReport; add SessionEvent, AnalysisResult
- Simplify writer: remove Recommender class (LLM now produces recommendations directly)
- Update tests for new analyzer and models
2026-03-07 14:15:49 -08:00
chopratejas
7cf086c2e8 Add multi-agent support, quality gates, and integration tests for headroom learn
- Codex adapter: CodexScanner reads ~/.codex/sessions/*.json, CodexWriter
  writes to AGENTS.md + instructions.md. Tested on 328 real sessions.
- Gemini writer: GeminiWriter writes to GEMINI.md (scanner deferred,
  sessions stored in protobuf).
- CLI --agent flag: auto-detect available agents or specify claude/codex/gemini.
- Quality gates: min_evidence, min_confidence, min_total_evidence thresholds
  prevent weak signals from writing noise to project files.
- Integration tests against real Claude Code and Codex session data on disk.
  Tests skip gracefully if data directories don't exist.
- Bash path extraction for Codex (reads files via sed/cat, not Read tool).
- Idempotency, false positive filtering, and skip-write-on-empty tests.
2026-02-28 23:29:02 -08:00
chopratejas
17442c2dcc Add headroom learn: offline failure learning for coding agents
Analyzes past conversation history to find tool call failure patterns,
correlates each failure with what eventually succeeded, and writes
specific project-level learnings to CLAUDE.md and MEMORY.md.

Key design:
- Success correlation: extracts the diff between failed and successful
  inputs as the learning (not generic advice)
- Generic architecture: tool-agnostic ToolCall model with pluggable
  Scanner/Writer adapters (Claude Code first, extensible to Cursor/Codex)
- 5 analyzers: Environment, Structure, Commands, Retries, Cross-Session
- Dry-run by default, --apply to write, --all for all projects

Also fixes mypy errors in litellm_callback, asgi, langchain chat_model,
and anthropic provider (AsyncClient typing, ToolCall arg-type, int cast).
2026-02-27 21:19:03 -08:00