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.
This commit is contained in:
Focused Instability 2026-06-22 22:05:06 +02:00 committed by GitHub
parent 84f9871e30
commit ced75e4718
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 443 additions and 32 deletions

View file

@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Features
* **learn:** write per-project learnings to the personal, gitignored `CLAUDE.local.md` by default instead of the team-shared `CLAUDE.md`, matching Claude Code's memory convention so machine-specific paths and tool-discovery byproducts no longer pollute the shared file. Adds a `--target` flag to override the destination (e.g. `--target CLAUDE.md` to opt back into the shared file, or any custom path), and auto-migrates a stale learned-patterns block out of an existing `CLAUDE.md` into `CLAUDE.local.md` with a warning ([#1072](https://github.com/chopratejas/headroom/issues/1072)).
* **proxy:** measure and surface rolling and current token throughput metrics (active/wall-clock input, compression, effective forward, and streamed generation) in `headroom perf` CLI and the dashboard ([#959](https://github.com/chopratejas/headroom/issues/959)).
* **vibe:** add Mistral Vibe CLI support with `headroom wrap vibe`.
* **proxy:** per-project savings breakdown on the dashboard for all wrapped agents — Claude Code, Codex, aider, Copilot, and Cursor ([#802](https://github.com/chopratejas/headroom/issues/802)). `headroom wrap claude`/`codex` tag requests with an `X-Headroom-Project` header (launch-directory name); `wrap aider`/`copilot`/`cursor` — whose clients cannot send custom headers — use a `/p/<name>` base-URL prefix the proxy strips. Savings are aggregated per project (persisted, schema v3 with transparent v2 migration), exposed as `savings.per_project` in `/stats` and `projects` in `/stats-history`, and shown in a Per-Project Savings dashboard table.

View file

@ -11,7 +11,7 @@ description: Offline failure analysis for coding agents. Analyzes past sessions,
# See recommendations for current project (dry-run, no changes)
headroom learn
# Write recommendations to CLAUDE.md and MEMORY.md
# Write recommendations to CLAUDE.local.md and MEMORY.md
headroom learn --apply
# Analyze a specific project
@ -19,6 +19,9 @@ headroom learn --project ~/my-project --apply
# Analyze all projects
headroom learn --all --apply
# Write to the team-shared CLAUDE.md instead of CLAUDE.local.md
headroom learn --apply --target CLAUDE.md
```
## Success Correlation
@ -84,10 +87,22 @@ Files that need `offset`/`limit` with Read.
| Pattern | Destination | Why |
|---------|-------------|-----|
| Environment, paths, search scope, commands, large files | **CLAUDE.md** | Stable project facts, version-controllable |
| Environment, paths, search scope, commands, large files | **CLAUDE.local.md** | Personal facts (machine-specific paths), gitignored by default |
| Missing paths, retry patterns, permissions | **MEMORY.md** | May change, agent-specific |
CLAUDE.md lives in your project directory. MEMORY.md lives in `~/.claude/projects/*/memory/`.
`CLAUDE.local.md` lives in your project directory. It is the **personal** local
memory file in Claude Code's [memory convention](https://docs.claude.com/en/docs/claude-code/memory)
— meant to be gitignored — so machine-specific learnings (absolute paths,
tool-discovery byproducts) don't pollute the team-shared `CLAUDE.md`. Make sure
`CLAUDE.local.md` is listed in your `.gitignore`. Pass `--target CLAUDE.md` to
opt into the shared file instead, or `--target <path>` for any custom location.
MEMORY.md lives in `~/.claude/projects/*/memory/`.
If an older Headroom version already wrote a learned-patterns block into your
team-shared `CLAUDE.md`, the next `headroom learn --apply` moves it into
`CLAUDE.local.md` and prints a warning so you can review the diff before
committing. If `CLAUDE.md` contained nothing but the Headroom block, it is
removed entirely.
## Marker-Based Updates
@ -122,6 +137,8 @@ Options:
--project PATH Project directory to analyze (default: current directory)
--all Analyze all discovered projects
--apply Write recommendations (default: dry-run)
--target TEXT Context file to write to, Claude Code only (default:
CLAUDE.local.md). Relative to project root, or absolute.
--agent TEXT Agent to analyze (e.g. claude, codex, gemini)
--model TEXT LLM model to use for analysis
--workers INT Number of parallel workers

View file

@ -77,6 +77,14 @@ Use 'auto' (default) to scan all detected agents."""
default=False,
help="Write recommendations to context/memory files (default: dry-run).",
)
@click.option(
"--target",
type=str,
default=None,
help="Override the context file learnings are written to (Claude Code only). "
"Path is relative to the project root, or absolute. Defaults to CLAUDE.local.md "
"(personal, gitignored). Pass CLAUDE.md to write to the team-shared file instead.",
)
@click.option(
"--agent",
type=_AgentChoice(),
@ -124,6 +132,7 @@ def learn(
project: Path | None,
analyze_all: bool,
apply: bool,
target: str | None,
agent: str,
model: str | None,
workers: int | None,
@ -148,6 +157,7 @@ def learn(
headroom learn --model gpt-4o # Use GPT-4o for analysis
headroom learn --all # Analyze all projects
headroom learn --agent codex --all # Analyze all Codex sessions
headroom learn --target CLAUDE.md # Write to the team-shared file
"""
import os
@ -200,6 +210,11 @@ def learn(
for agent_name, plugin in agent_configs:
writer = plugin.create_writer()
if target is not None:
if hasattr(writer, "set_context_target"):
writer.set_context_target(target)
else:
click.echo(f"Note: --target is not supported for {agent_name}; ignoring.")
all_projects = plugin.discover_projects()
if not all_projects:
continue
@ -280,6 +295,9 @@ def learn(
)
continue
for warning in getattr(result, "warnings", None) or []:
click.echo(f"\n{warning}")
for file_path, content in result.content_by_file.items():
click.echo(f"\n {'[WOULD WRITE]' if result.dry_run else '[WROTE]'} {file_path}")
click.echo(f" {'' * 50}")

View file

@ -72,6 +72,8 @@ class WriteResult:
self.files_written: list[Path] = []
self.content_by_file: dict[Path, str] = {}
self.dry_run: bool = True
# Human-readable notices (e.g. legacy CLAUDE.md migration) surfaced by the CLI.
self.warnings: list[str] = []
def add(self, path: Path, content: str) -> None:
self.files_written.append(path)
@ -190,13 +192,46 @@ def _merge_into_file(file_path: Path, new_recommendations: list[Recommendation])
return section + "\n"
def _strip_marker_block(content: str) -> str:
"""Remove the headroom:learn marker block from text, tidying blank lines.
Used when migrating a stale block out of the team-shared CLAUDE.md into the
personal CLAUDE.local.md. Returns "" if nothing but the block remained.
"""
cleaned = _MARKER_PATTERN.sub("", content)
cleaned = re.sub(r"\n{3,}", "\n\n", cleaned).strip()
return cleaned + "\n" if cleaned else ""
# =============================================================================
# Claude Code Writer
# =============================================================================
class ClaudeCodeWriter(ContextWriter):
"""Writes learned patterns to CLAUDE.md and MEMORY.md for Claude Code."""
"""Writes learned patterns to CLAUDE.local.md and MEMORY.md for Claude Code.
Project-level learnings default to ``CLAUDE.local.md`` rather than
``CLAUDE.md``: per Claude Code's memory convention ``CLAUDE.md`` is
team-shared and checked into git, while ``CLAUDE.local.md`` is personal and
gitignored by default. Learned patterns are personal-by-default (they hold
machine-specific absolute paths and tool-discovery byproducts), so writing
them to the shared file pollutes it for teammates (issue #1072).
Pass an explicit target via :meth:`set_context_target` (CLI ``--target``) to
override -- e.g. ``CLAUDE.md`` to opt back into the shared file.
"""
def __init__(self, context_target: str | None = None) -> None:
# Explicit write target for CONTEXT_FILE recs (overrides the default).
self._context_target = context_target
def set_context_target(self, context_target: str | None) -> None:
"""Override where CONTEXT_FILE recommendations are written.
Accepts a path relative to the project root or an absolute path.
"""
self._context_target = context_target
def write(
self,
@ -211,12 +246,18 @@ class ClaudeCodeWriter(ContextWriter):
memory_recs = [r for r in recommendations if r.target == RecommendationTarget.MEMORY_FILE]
if context_recs:
claude_md_path = self._resolve_context_path(project)
full_content = _merge_into_file(claude_md_path, context_recs)
result.add(claude_md_path, full_content)
target_path = self._resolve_context_path(project)
# Migrate any stale block left in the team-shared CLAUDE.md by older
# headroom versions into the new target, then strip it from CLAUDE.md
# so the shared file is no longer polluted.
migrated = self._migrate_legacy_block(project, target_path, result, dry_run)
new_sections = {r.section for r in context_recs}
merged_recs = context_recs + [r for r in migrated if r.section not in new_sections]
full_content = _merge_into_file(target_path, merged_recs)
result.add(target_path, full_content)
if not dry_run:
claude_md_path.parent.mkdir(parents=True, exist_ok=True)
claude_md_path.write_text(full_content, encoding="utf-8")
target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_text(full_content, encoding="utf-8")
if memory_recs:
memory_path = self._resolve_memory_path(project)
@ -229,13 +270,68 @@ class ClaudeCodeWriter(ContextWriter):
return result
def _resolve_context_path(self, project: ProjectInfo) -> Path:
if project.context_file:
return project.context_file
# If project path is the home directory, write to ~/.claude/CLAUDE.md
# (the global location Claude Code reads) instead of ~/CLAUDE.md
# Explicit --target wins over every default.
if self._context_target is not None:
target = Path(self._context_target).expanduser()
return target if target.is_absolute() else project.project_path / target
# The home directory's CLAUDE.md (~/.claude/CLAUDE.md) is the user's
# personal global memory, not a team-shared file, so keep writing there.
if project.project_path == Path.home():
return Path.home() / ".claude" / "CLAUDE.md"
return project.project_path / "CLAUDE.md"
# Project level: default to the gitignored, personal CLAUDE.local.md so
# we never pollute the team-shared CLAUDE.md (issue #1072).
return project.project_path / "CLAUDE.local.md"
def _migrate_legacy_block(
self,
project: ProjectInfo,
target_path: Path,
result: WriteResult,
dry_run: bool,
) -> list[Recommendation]:
"""Move a stale headroom block out of CLAUDE.md into the new target.
Only fires for the default project-level case (no explicit --target, not
the home directory) when CLAUDE.md still carries a marker block and the
new target doesn't yet own one. Returns the migrated recommendations so
the caller can carry them forward; records the cleaned CLAUDE.md and a
warning on ``result``. Honors ``dry_run`` (no writes, warning still set).
"""
legacy_path = project.project_path / "CLAUDE.md"
if self._context_target is not None or project.project_path == Path.home():
return []
if target_path == legacy_path or not legacy_path.exists():
return []
legacy_text = legacy_path.read_text(encoding="utf-8")
if _MARKER_START not in legacy_text:
return []
# If the target already owns a block, it is the source of truth -- don't
# double-migrate or clobber accumulated learnings.
if target_path.exists() and _MARKER_START in target_path.read_text(encoding="utf-8"):
return []
migrated = _parse_prior_recommendations(legacy_text)
cleaned = _strip_marker_block(legacy_text)
gitignore_hint = f" Ensure {target_path.name} is in your .gitignore so it stays personal."
if cleaned:
# CLAUDE.md has hand-written content too — keep it, drop only the block.
result.add(legacy_path, cleaned)
result.warnings.append(
f"Moved Headroom learnings out of {legacy_path} into {target_path}: "
f"CLAUDE.md is team-shared, so personal learnings now live in "
f"{target_path.name}. Review the diff before committing.{gitignore_hint}"
)
if not dry_run:
legacy_path.write_text(cleaned, encoding="utf-8")
else:
# CLAUDE.md held nothing but the Headroom block — remove the husk.
result.warnings.append(
f"Removed {legacy_path} (it contained only Headroom learnings) and "
f"moved them into {target_path}.{gitignore_hint}"
)
if not dry_run:
legacy_path.unlink()
return migrated
def _resolve_memory_path(self, project: ProjectInfo) -> Path:
if project.memory_file:

View file

@ -325,3 +325,82 @@ def test_learn_main_only_flag_threads_to_scanner(
)
assert result.exit_code == 0, result.output
assert plugin.last_include_subagents is False
class TargetAwareWriter(FakeWriter):
"""A writer that supports --target and surfaces a migration warning."""
def __init__(self) -> None:
super().__init__()
self.context_target: str | None = None
def set_context_target(self, target: str | None) -> None:
self.context_target = target
def write(self, recommendations, project, dry_run: bool): # noqa: ANN001, ANN201
self.calls.append((recommendations, project, dry_run))
return SimpleNamespace(
dry_run=dry_run,
content_by_file={
Path(project.project_path) / "CLAUDE.local.md": "<!-- headroom -->\nRule 1"
},
warnings=["Moved Headroom learnings out of CLAUDE.md into CLAUDE.local.md."],
)
def test_learn_target_threads_to_writer_and_prints_warnings(
monkeypatch: pytest.MonkeyPatch, runner: CliRunner, tmp_path: Path
) -> None:
project_path = tmp_path / "proj"
project_path.mkdir()
proj = SimpleNamespace(name="proj", project_path=project_path)
plugin = FakePlugin("claude", "Claude Code", [proj])
plugin.writer = TargetAwareWriter()
monkeypatch.setattr("headroom.learn.analyzer._detect_default_model", lambda: "gpt-4o")
monkeypatch.setattr("headroom.learn.registry.get_plugin", lambda name: plugin)
monkeypatch.setattr("headroom.learn.analyzer.SessionAnalyzer", FakeAnalyzer)
result = runner.invoke(
main,
[
"learn",
"--agent",
"claude",
"--project",
str(project_path),
"--apply",
"--target",
"CLAUDE.md",
],
catch_exceptions=False,
)
assert result.exit_code == 0, result.output
# --target is threaded into the writer...
assert plugin.writer.context_target == "CLAUDE.md"
# ...and the writer's warnings are surfaced to the user.
assert "Moved Headroom learnings" in result.output
def test_learn_target_ignored_for_unsupported_agent(
monkeypatch: pytest.MonkeyPatch, runner: CliRunner, tmp_path: Path
) -> None:
project_path = tmp_path / "proj"
project_path.mkdir()
proj = SimpleNamespace(name="proj", project_path=project_path)
# FakePlugin's FakeWriter has no set_context_target, so --target is unsupported.
plugin = FakePlugin("codex", "Codex", [proj])
monkeypatch.setattr("headroom.learn.analyzer._detect_default_model", lambda: "gpt-4o")
monkeypatch.setattr("headroom.learn.registry.get_plugin", lambda name: plugin)
monkeypatch.setattr("headroom.learn.analyzer.SessionAnalyzer", FakeAnalyzer)
result = runner.invoke(
main,
["learn", "--agent", "codex", "--project", str(project_path), "--target", "CLAUDE.md"],
catch_exceptions=False,
)
assert result.exit_code == 0, result.output
assert "Note: --target is not supported for codex" in result.output

View file

@ -83,11 +83,11 @@ class TestIdempotency:
writer = ClaudeCodeWriter()
# First write
writer.write(recs, proj, dry_run=False)
first_content = (tmp_path / "CLAUDE.md").read_text()
first_content = (tmp_path / "CLAUDE.local.md").read_text()
# Second write (same recs)
writer.write(recs, proj, dry_run=False)
second_content = (tmp_path / "CLAUDE.md").read_text()
second_content = (tmp_path / "CLAUDE.local.md").read_text()
# Content should be identical (replaced, not appended)
assert first_content == second_content
@ -216,7 +216,7 @@ class TestClaudeCodeIntegration:
assert write_result.dry_run is True
for fp in write_result.files_written:
assert "CLAUDE.md" in fp.name or "MEMORY.md" in fp.name
assert "CLAUDE" in fp.name or "MEMORY" in fp.name
class TestDecodeProjectPath:

View file

@ -47,10 +47,12 @@ class TestClaudeCodeWriter:
assert result.dry_run is True
assert len(result.files_written) == 1
# File should NOT exist (dry run)
claude_md = proj.project_path / "CLAUDE.md"
assert not claude_md.exists()
claude_local = proj.project_path / "CLAUDE.local.md"
assert not claude_local.exists()
# Default target is the personal CLAUDE.local.md, never the shared CLAUDE.md
assert result.files_written[0].name == "CLAUDE.local.md"
def test_apply_writes_claude_md(self, tmp_path):
def test_apply_writes_claude_local_md(self, tmp_path):
proj = _project(tmp_path)
writer = ClaudeCodeWriter()
recs = [_rec(RecommendationTarget.CONTEXT_FILE, "Environment", "- Use `uv run python`")]
@ -58,12 +60,15 @@ class TestClaudeCodeWriter:
result = writer.write(recs, proj, dry_run=False)
assert result.dry_run is False
claude_md = proj.project_path / "CLAUDE.md"
assert claude_md.exists()
content = claude_md.read_text()
# Learnings go to the personal, gitignored CLAUDE.local.md by default...
claude_local = proj.project_path / "CLAUDE.local.md"
assert claude_local.exists()
content = claude_local.read_text()
assert "uv run python" in content
assert _MARKER_START in content
assert _MARKER_END in content
# ...and never touch the team-shared CLAUDE.md.
assert not (proj.project_path / "CLAUDE.md").exists()
def test_apply_writes_memory_md(self, tmp_path):
proj = _project(tmp_path)
@ -76,24 +81,26 @@ class TestClaudeCodeWriter:
assert memory_md.exists()
assert "Don't retry globs" in memory_md.read_text()
def test_preserves_existing_claude_md_content(self, tmp_path):
def test_hand_written_claude_md_left_untouched(self, tmp_path):
proj = _project(tmp_path)
claude_md = proj.project_path / "CLAUDE.md"
claude_md.write_text("# My Project\n\nExisting instructions here.\n")
original = "# My Project\n\nExisting instructions here.\n"
claude_md.write_text(original)
writer = ClaudeCodeWriter()
recs = [_rec(RecommendationTarget.CONTEXT_FILE, "Environment", "- Use uv")]
writer.write(recs, proj, dry_run=False)
content = claude_md.read_text()
assert "My Project" in content
assert "Existing instructions here" in content
assert "Use uv" in content
# A hand-written CLAUDE.md with no headroom block is left exactly as-is.
assert claude_md.read_text() == original
# Learnings land in the personal CLAUDE.local.md instead.
local_content = (proj.project_path / "CLAUDE.local.md").read_text()
assert "Use uv" in local_content
def test_carries_forward_prior_sections_not_resurfaced(self, tmp_path):
"""Re-running learn must not drop prior sections that the new run didn't re-surface."""
proj = _project(tmp_path)
claude_md = proj.project_path / "CLAUDE.md"
claude_md = proj.project_path / "CLAUDE.local.md"
prior_block = (
f"# My Project\n\n{_MARKER_START}\n"
"## Headroom Learned Patterns\n"
@ -129,7 +136,7 @@ class TestClaudeCodeWriter:
def test_new_run_overrides_same_named_prior_section(self, tmp_path):
"""When a section appears in both prior and new, the new run wins."""
proj = _project(tmp_path)
claude_md = proj.project_path / "CLAUDE.md"
claude_md = proj.project_path / "CLAUDE.local.md"
prior_block = (
f"{_MARKER_START}\n"
"## Headroom Learned Patterns\n"
@ -203,7 +210,7 @@ class TestClaudeCodeWriter:
def test_section_without_tokens_annotation_round_trips(self, tmp_path):
"""Prior sections emitted without a tokens annotation must still carry forward cleanly."""
proj = _project(tmp_path)
claude_md = proj.project_path / "CLAUDE.md"
claude_md = proj.project_path / "CLAUDE.local.md"
claude_md.write_text(
f"{_MARKER_START}\n"
"## Headroom Learned Patterns\n"
@ -240,6 +247,199 @@ class TestClaudeCodeWriter:
assert "New pattern" in content
def _legacy_block(section: str, body: str) -> str:
return (
f"# My Project\n\nExisting instructions.\n\n{_MARKER_START}\n"
"## Headroom Learned Patterns\n"
"*Auto-generated by `headroom learn` on 2026-01-01 — do not edit manually*\n\n"
f"### {section}\n{body}\n\n"
f"{_MARKER_END}\n"
)
class TestContextTargetOverride:
"""--target / set_context_target controls where CONTEXT_FILE recs are written."""
def test_target_override_relative_path(self, tmp_path):
proj = _project(tmp_path)
writer = ClaudeCodeWriter()
writer.set_context_target("CLAUDE.md")
recs = [_rec(RecommendationTarget.CONTEXT_FILE, "Environment", "- Use uv")]
writer.write(recs, proj, dry_run=False)
# Explicit target opts back into the team-shared CLAUDE.md.
assert (proj.project_path / "CLAUDE.md").exists()
assert "Use uv" in (proj.project_path / "CLAUDE.md").read_text()
assert not (proj.project_path / "CLAUDE.local.md").exists()
def test_target_override_via_constructor(self, tmp_path):
proj = _project(tmp_path)
writer = ClaudeCodeWriter(context_target="docs/LEARNINGS.md")
recs = [_rec(RecommendationTarget.CONTEXT_FILE, "Environment", "- Use uv")]
writer.write(recs, proj, dry_run=False)
target = proj.project_path / "docs" / "LEARNINGS.md"
assert target.exists()
assert "Use uv" in target.read_text()
def test_target_absolute_path(self, tmp_path):
proj = _project(tmp_path)
abs_target = tmp_path / "elsewhere" / "NOTES.md"
writer = ClaudeCodeWriter(context_target=str(abs_target))
recs = [_rec(RecommendationTarget.CONTEXT_FILE, "Environment", "- Use uv")]
writer.write(recs, proj, dry_run=False)
assert abs_target.exists()
assert "Use uv" in abs_target.read_text()
class TestLegacyClaudeMdMigration:
"""A stale headroom block in the shared CLAUDE.md migrates to CLAUDE.local.md."""
def test_migrates_block_and_strips_legacy(self, tmp_path):
proj = _project(tmp_path)
claude_md = proj.project_path / "CLAUDE.md"
claude_md.write_text(_legacy_block("Build Commands", "- cargo check from src-tauri/"))
writer = ClaudeCodeWriter()
recs = [_rec(RecommendationTarget.CONTEXT_FILE, "Environment", "- Use uv")]
result = writer.write(recs, proj, dry_run=False)
# Hand-written content stays in CLAUDE.md; the headroom block is gone.
legacy = claude_md.read_text()
assert "Existing instructions." in legacy
assert _MARKER_START not in legacy
assert "Build Commands" not in legacy
# CLAUDE.local.md now owns the migrated section AND the new one.
local = (proj.project_path / "CLAUDE.local.md").read_text()
assert "### Build Commands" in local
assert "cargo check from src-tauri/" in local
assert "### Environment" in local
assert "Use uv" in local
assert local.count(_MARKER_START) == 1
# The migration is surfaced to the user.
assert any("CLAUDE.md" in w for w in result.warnings)
def test_block_only_claude_md_is_removed(self, tmp_path):
proj = _project(tmp_path)
claude_md = proj.project_path / "CLAUDE.md"
# CLAUDE.md holds nothing but the Headroom block (no hand-written content).
claude_md.write_text(
f"{_MARKER_START}\n## Headroom Learned Patterns\n\n"
"### Build Commands\n- cargo check\n\n"
f"{_MARKER_END}\n"
)
writer = ClaudeCodeWriter()
recs = [_rec(RecommendationTarget.CONTEXT_FILE, "Environment", "- Use uv")]
result = writer.write(recs, proj, dry_run=False)
# The empty husk is deleted rather than left behind as an empty file.
assert not claude_md.exists()
local = (proj.project_path / "CLAUDE.local.md").read_text()
assert "### Build Commands" in local
assert "### Environment" in local
assert any("Removed" in w for w in result.warnings)
def test_dry_run_block_only_claude_md_not_removed(self, tmp_path):
proj = _project(tmp_path)
claude_md = proj.project_path / "CLAUDE.md"
original = (
f"{_MARKER_START}\n## Headroom Learned Patterns\n\n"
"### Build Commands\n- cargo check\n\n"
f"{_MARKER_END}\n"
)
claude_md.write_text(original)
writer = ClaudeCodeWriter()
recs = [_rec(RecommendationTarget.CONTEXT_FILE, "Environment", "- Use uv")]
result = writer.write(recs, proj, dry_run=True)
# Dry run leaves the file on disk but still previews the removal.
assert claude_md.read_text() == original
assert any("Removed" in w for w in result.warnings)
def test_dry_run_migration_writes_nothing(self, tmp_path):
proj = _project(tmp_path)
claude_md = proj.project_path / "CLAUDE.md"
original = _legacy_block("Build Commands", "- cargo check")
claude_md.write_text(original)
writer = ClaudeCodeWriter()
recs = [_rec(RecommendationTarget.CONTEXT_FILE, "Environment", "- Use uv")]
result = writer.write(recs, proj, dry_run=True)
# Nothing written on disk, but the warning still fires for the preview.
assert claude_md.read_text() == original
assert not (proj.project_path / "CLAUDE.local.md").exists()
assert any("CLAUDE.md" in w for w in result.warnings)
def test_no_migration_when_local_already_owns_block(self, tmp_path):
proj = _project(tmp_path)
claude_md = proj.project_path / "CLAUDE.md"
legacy = _legacy_block("Build Commands", "- cargo check")
claude_md.write_text(legacy)
local_md = proj.project_path / "CLAUDE.local.md"
local_md.write_text(
f"{_MARKER_START}\n## Headroom Learned Patterns\n\n"
"### Environment\n- prior local note\n\n"
f"{_MARKER_END}\n"
)
writer = ClaudeCodeWriter()
recs = [_rec(RecommendationTarget.CONTEXT_FILE, "Environment", "- fresh note")]
result = writer.write(recs, proj, dry_run=False)
# CLAUDE.md is left untouched (local is already the source of truth).
assert claude_md.read_text() == legacy
assert not result.warnings
local = local_md.read_text()
assert "fresh note" in local
assert "prior local note" not in local
def test_target_override_skips_migration(self, tmp_path):
proj = _project(tmp_path)
claude_md = proj.project_path / "CLAUDE.md"
legacy = _legacy_block("Build Commands", "- cargo check")
claude_md.write_text(legacy)
writer = ClaudeCodeWriter()
writer.set_context_target("CLAUDE.md")
recs = [_rec(RecommendationTarget.CONTEXT_FILE, "Environment", "- Use uv")]
result = writer.write(recs, proj, dry_run=False)
# Explicit CLAUDE.md target merges in place, no migration warning.
assert not result.warnings
content = claude_md.read_text()
assert "### Environment" in content
assert "### Build Commands" in content
class TestHomeDirectoryContext:
"""The home directory keeps writing to ~/.claude/CLAUDE.md (personal global memory)."""
def test_home_dir_writes_global_claude_md(self, tmp_path, monkeypatch):
fake_home = tmp_path / "home"
fake_home.mkdir()
monkeypatch.setattr(Path, "home", classmethod(lambda cls: fake_home))
proj = ProjectInfo(
name="home",
project_path=fake_home,
data_path=tmp_path / "data",
)
writer = ClaudeCodeWriter()
recs = [_rec(RecommendationTarget.CONTEXT_FILE, "Environment", "- Use uv")]
writer.write(recs, proj, dry_run=False)
global_md = fake_home / ".claude" / "CLAUDE.md"
assert global_md.exists()
assert "Use uv" in global_md.read_text()
assert not (fake_home / "CLAUDE.local.md").exists()
class TestParsePriorRecommendations:
"""Direct coverage for _parse_prior_recommendations edge cases."""