mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-10 14:27:00 -04:00
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:
parent
84f9871e30
commit
ced75e4718
7 changed files with 443 additions and 32 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue