Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions src/openharness/config/paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,24 @@
_CONFIG_FILE_NAME = "settings.json"


def get_config_dir() -> Path:
"""Return the configuration directory, creating it if needed.
def get_config_dir(*, create: bool = True) -> Path:
"""Return the configuration directory.

Resolution order:
1. OPENHARNESS_CONFIG_DIR environment variable
2. ~/.openharness/

Creates the directory unless ``create=False`` — used by read-only callers
(e.g. instruction-file discovery) that must not have filesystem side effects.
"""
env_dir = os.environ.get("OPENHARNESS_CONFIG_DIR")
if env_dir:
config_dir = Path(env_dir)
else:
config_dir = Path.home() / _DEFAULT_BASE_DIR

config_dir.mkdir(parents=True, exist_ok=True)
if create:
config_dir.mkdir(parents=True, exist_ok=True)
return config_dir


Expand Down
44 changes: 32 additions & 12 deletions src/openharness/prompts/claudemd.py
Original file line number Diff line number Diff line change
@@ -1,35 +1,55 @@
"""CLAUDE.md discovery and loading."""
"""Project instruction discovery and loading (CLAUDE.md + AGENTS.md)."""

from __future__ import annotations

from pathlib import Path

from openharness.config.paths import get_config_dir

# Per-directory instruction filenames, in load order. AGENTS.md is the
# cross-tool standard (Codex et al.); CLAUDE.md is OpenHarness-native. Both are
# loaded when present so a repo authored for either convention works unchanged.
_INSTRUCTION_FILENAMES = ("AGENTS.md", "CLAUDE.md")


def discover_claude_md_files(cwd: str | Path) -> list[Path]:
"""Discover relevant CLAUDE.md instruction files from the cwd upward."""
"""Discover project instruction files (AGENTS.md / CLAUDE.md) from cwd upward.

Walks from ``cwd`` to the filesystem root collecting, at each level, any
``AGENTS.md`` / ``CLAUDE.md`` / ``.claude/CLAUDE.md`` plus ``.claude/rules/*.md``.
Order is most-specific first (cwd → parents → root); the least-specific global
fallback ``~/.openharness/AGENTS.md`` is appended last, matching the existing
root-last convention so repo/dir instructions take precedence over it.
"""
current = Path(cwd).resolve()
results: list[Path] = []
seen: set[Path] = set()

def add(candidate: Path) -> None:
if candidate not in seen and candidate.exists():
results.append(candidate)
seen.add(candidate)

for directory in [current, *current.parents]:
for candidate in (
directory / "CLAUDE.md",
directory / ".claude" / "CLAUDE.md",
):
if candidate.exists() and candidate not in seen:
results.append(candidate)
seen.add(candidate)
for name in _INSTRUCTION_FILENAMES:
add(directory / name)
add(directory / ".claude" / "CLAUDE.md")

rules_dir = directory / ".claude" / "rules"
if rules_dir.is_dir():
for rule in sorted(rules_dir.glob("*.md")):
if rule not in seen:
results.append(rule)
seen.add(rule)
add(rule)

if directory.parent == directory:
break

# Least-specific global instructions, loaded last (overridable by repo/dir).
# create=False: discovery must not materialize ~/.openharness as a side effect.
try:
add(get_config_dir(create=False) / "AGENTS.md")
except OSError:
pass

return results


Expand Down
67 changes: 67 additions & 0 deletions tests/test_prompts/test_claudemd.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,73 @@ def test_discover_claude_md_files(tmp_path: Path):
assert rules_dir / "python.md" in files


def test_discover_finds_agents_md(tmp_path: Path):
"""A repo authored with the cross-tool AGENTS.md convention is picked up."""
repo = tmp_path / "repo"
nested = repo / "pkg"
nested.mkdir(parents=True)
(repo / "AGENTS.md").write_text("agents-standard instructions", encoding="utf-8")

files = discover_claude_md_files(nested)

assert repo / "AGENTS.md" in files


def test_discover_loads_both_agents_and_claude_md(tmp_path: Path):
"""When both files exist in a dir, both are discovered."""
repo = tmp_path / "repo"
repo.mkdir()
(repo / "AGENTS.md").write_text("agents", encoding="utf-8")
(repo / "CLAUDE.md").write_text("claude", encoding="utf-8")

files = discover_claude_md_files(repo)

assert repo / "AGENTS.md" in files
assert repo / "CLAUDE.md" in files


def test_discover_appends_global_agents_md_last(tmp_path: Path, monkeypatch):
"""~/.openharness/AGENTS.md is the least-specific fallback, loaded last."""
config_dir = tmp_path / "cfg"
config_dir.mkdir()
(config_dir / "AGENTS.md").write_text("global instructions", encoding="utf-8")
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(config_dir))

repo = tmp_path / "repo"
repo.mkdir()
(repo / "AGENTS.md").write_text("repo instructions", encoding="utf-8")

files = discover_claude_md_files(repo)

assert config_dir / "AGENTS.md" in files
# Repo-local instructions precede the global fallback.
assert files.index(repo / "AGENTS.md") < files.index(config_dir / "AGENTS.md")


def test_discover_does_not_create_config_dir(tmp_path: Path, monkeypatch):
"""Discovery is read-only: it must not materialize the config dir."""
config_dir = tmp_path / "cfg" # deliberately not created
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(config_dir))
repo = tmp_path / "repo"
repo.mkdir()

discover_claude_md_files(repo)

assert not config_dir.exists()


def test_load_agents_md_prompt(tmp_path: Path):
repo = tmp_path / "repo"
repo.mkdir()
(repo / "AGENTS.md").write_text("prefer ripgrep", encoding="utf-8")

prompt = load_claude_md_prompt(repo)

assert prompt is not None
assert "Project Instructions" in prompt
assert "prefer ripgrep" in prompt


def test_load_claude_md_prompt(tmp_path: Path):
repo = tmp_path / "repo"
repo.mkdir()
Expand Down