diff --git a/AGENTS.md b/AGENTS.md index a7ad38578..651129b85 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # solstone Developer Guide -This is the developer-facing documentation for the solstone codebase. If you're an AI agent working **inside a journal**, read the journal's own `AGENTS.md` instead — it's seeded from `docs/JOURNAL.md` and tells you about the journal layout. +This is the developer-facing documentation for the solstone codebase. If you're an AI agent working **inside a journal**, read the journal template at `journal/AGENTS.md`; journal-side agents discover the full layout and CLI reference via `.claude/skills/journal/` or `.agents/skills/journal/`. ## Key Concepts @@ -63,7 +63,3 @@ For deeper material, see: - `docs/coding-standards.md` - `docs/testing.md` - `docs/environment.md` - -## Known limitations - -- Per-journal `AGENTS.md` files are seeded once at journal init by `apps/sol/maint/003_seed_agents_md.py`. They are not automatically refreshed if `docs/JOURNAL.md` changes upstream. Re-seed manually by deleting the journal's `AGENTS.md` and restarting the supervisor. diff --git a/apps/sol/maint/003_seed_agents_md.py b/apps/sol/maint/003_seed_agents_md.py deleted file mode 100644 index 7d721af55..000000000 --- a/apps/sol/maint/003_seed_agents_md.py +++ /dev/null @@ -1,76 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""Seed per-journal AGENTS.md, CLAUDE.md, and GEMINI.md files.""" - -from __future__ import annotations - -import argparse -import sys -from pathlib import Path - -from think.utils import get_journal, setup_cli - - -def _symlink_points_to_agents(path: Path) -> bool: - return path.is_symlink() and path.readlink() == Path("AGENTS.md") - - -def _repair_symlink(path: Path) -> int: - if path.exists() and not path.is_symlink(): - print(f"refusing to replace existing non-symlink {path.name}", file=sys.stderr) - return 1 - if path.is_symlink(): - path.unlink() - action = "replaced" - else: - action = "created" - path.symlink_to("AGENTS.md") - print(f"{action} {path.name}") - return 0 - - -# AGENTS.md is derived from docs/JOURNAL.md. Drift-detection: direct content -# compare. Hand-edit policy: overwrite on mismatch with stderr warning -# (prior content recoverable from git history). -def main() -> int: - parser = argparse.ArgumentParser(description="Seed journal AGENTS.md symlinks.") - setup_cli(parser) - - journal = Path(get_journal()) - repo_root = Path(__file__).resolve().parents[3] - - agents_path = journal / "AGENTS.md" - claude_path = journal / "CLAUDE.md" - gemini_path = journal / "GEMINI.md" - - try: - journal_md = (repo_root / "docs" / "JOURNAL.md").read_text(encoding="utf-8") - - if not agents_path.exists(): - agents_path.write_text(journal_md, encoding="utf-8") - print("created AGENTS.md") - elif agents_path.read_text(encoding="utf-8") != journal_md: - agents_path.write_text(journal_md, encoding="utf-8") - print( - "AGENTS.md content differed from docs/JOURNAL.md; refreshing " - "(previous content preserved in git history)", - file=sys.stderr, - ) - print("refreshed AGENTS.md") - - for path in (claude_path, gemini_path): - if _symlink_points_to_agents(path): - continue - status = _repair_symlink(path) - if status != 0: - return status - except OSError as exc: - print(f"failed to seed journal agent files: {exc}", file=sys.stderr) - return 1 - - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/gate_agents_rename.py b/scripts/gate_agents_rename.py index d24486906..e31b140aa 100644 --- a/scripts/gate_agents_rename.py +++ b/scripts/gate_agents_rename.py @@ -61,8 +61,6 @@ def is_allowed(path: Path) -> bool: return True if path == Path("scripts/gate_agents_rename.py"): return True - if path_str.startswith(".agents/skills/"): - return True if ALLOWLIST_RE.match(path_str): return True return False diff --git a/tests/test_generate_talents.py b/tests/test_generate_talents.py index b9a5d1576..754e32a55 100644 --- a/tests/test_generate_talents.py +++ b/tests/test_generate_talents.py @@ -12,7 +12,7 @@ def test_root_agents_md_is_hand_maintained(): assert content.startswith("# solstone Developer Guide") assert "generated from sol/identity.md" not in content assert "docs/project-structure.md" in content - assert "003_seed_agents_md.py" in content + assert "journal/AGENTS.md" in content def test_root_agent_symlinks_point_to_agents(): diff --git a/tests/test_journal_seeding_maint.py b/tests/test_journal_seeding_maint.py deleted file mode 100644 index 7a717fad0..000000000 --- a/tests/test_journal_seeding_maint.py +++ /dev/null @@ -1,83 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -import json -import os -import subprocess -import sys -from pathlib import Path - -import pytest - - -@pytest.fixture -def journal_path(tmp_path, monkeypatch): - monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) - config_dir = tmp_path / "config" - config_dir.mkdir() - (config_dir / "journal.json").write_text(json.dumps({})) - return tmp_path - - -def _run_main(journal_path): - env = os.environ.copy() - env["_SOLSTONE_JOURNAL_OVERRIDE"] = str(journal_path) - return subprocess.run( - [sys.executable, "-m", "apps.sol.maint.003_seed_agents_md"], - capture_output=True, - text=True, - check=False, - env=env, - ) - - -def test_seed_agents_md_creates_all_files(journal_path): - docs_text = Path("docs/JOURNAL.md").read_text(encoding="utf-8") - - result = _run_main(journal_path) - - assert result.returncode == 0 - agents_path = journal_path / "AGENTS.md" - claude_path = journal_path / "CLAUDE.md" - gemini_path = journal_path / "GEMINI.md" - assert agents_path.read_text(encoding="utf-8") == docs_text - assert claude_path.is_symlink() - assert claude_path.readlink() == Path("AGENTS.md") - assert gemini_path.is_symlink() - assert gemini_path.readlink() == Path("AGENTS.md") - - -def test_seed_agents_md_is_noop_when_already_seeded(journal_path): - docs_text = Path("docs/JOURNAL.md").read_text(encoding="utf-8") - agents_path = journal_path / "AGENTS.md" - agents_path.write_text(docs_text, encoding="utf-8") - (journal_path / "CLAUDE.md").symlink_to("AGENTS.md") - (journal_path / "GEMINI.md").symlink_to("AGENTS.md") - before = { - "agents": agents_path.stat().st_mtime_ns, - "claude": (journal_path / "CLAUDE.md").lstat().st_mtime_ns, - "gemini": (journal_path / "GEMINI.md").lstat().st_mtime_ns, - } - - result = _run_main(journal_path) - - after = { - "agents": agents_path.stat().st_mtime_ns, - "claude": (journal_path / "CLAUDE.md").lstat().st_mtime_ns, - "gemini": (journal_path / "GEMINI.md").lstat().st_mtime_ns, - } - assert result.returncode == 0 - assert before == after - - -def test_seed_agents_md_refreshes_on_drift(journal_path): - agents_path = journal_path / "AGENTS.md" - agents_path.write_text("stale content", encoding="utf-8") - (journal_path / "CLAUDE.md").symlink_to("AGENTS.md") - (journal_path / "GEMINI.md").symlink_to("AGENTS.md") - docs_text = Path("docs/JOURNAL.md").read_text(encoding="utf-8") - - result = _run_main(journal_path) - - assert result.returncode == 0 - assert agents_path.read_text(encoding="utf-8") == docs_text diff --git a/tests/test_journal_skill.py b/tests/test_journal_skill.py new file mode 100644 index 000000000..7f6caa4ef --- /dev/null +++ b/tests/test_journal_skill.py @@ -0,0 +1,109 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + + +def _repo_root() -> Path: + return Path(__file__).resolve().parent.parent + + +def _assert_inside_repo(path: Path, repo_root: Path) -> None: + resolved = path.resolve() + assert resolved.is_relative_to(repo_root) + + +def _tracked_symlinks(*roots: str) -> list[Path]: + repo_root = _repo_root() + result = subprocess.run( + ["git", "ls-files", *roots], + cwd=repo_root, + check=True, + capture_output=True, + text=True, + ) + return [ + repo_root / line + for line in result.stdout.splitlines() + if line and (repo_root / line).is_symlink() + ] + + +def test_journal_skill_references_exist_and_linked(): + repo_root = _repo_root() + skill_path = repo_root / "talent" / "journal" / "SKILL.md" + skill_text = skill_path.read_text(encoding="utf-8") + references = [ + "references/cli.md", + "references/config.md", + "references/facets.md", + "references/captures.md", + "references/logs.md", + "references/storage.md", + ] + + for rel_path in references: + ref_path = skill_path.parent / rel_path + assert ref_path.exists() + assert ref_path.read_text(encoding="utf-8").strip() + assert rel_path in skill_text + + +def test_journal_template_symlinks_resolve_inside_repo(): + repo_root = _repo_root() + for path in _tracked_symlinks("journal", "tests/fixtures/journal"): + _assert_inside_repo(path, repo_root) + + +def test_make_skills_idempotent(tmp_path): + repo_root = _repo_root() + temp_root = tmp_path / "repo" + temp_root.mkdir() + + shutil.copy2(repo_root / "Makefile", temp_root / "Makefile") + shutil.copytree(repo_root / "talent", temp_root / "talent", symlinks=True) + shutil.copytree(repo_root / "apps", temp_root / "apps", symlinks=True) + shutil.copytree(repo_root / "journal", temp_root / "journal", symlinks=True) + + subprocess.run( + ["make", "skills"], + cwd=temp_root, + check=True, + capture_output=True, + text=True, + ) + + def link_state(root: Path) -> dict[str, tuple[str, int]]: + return { + path.relative_to(root).as_posix(): ( + path.readlink().as_posix(), + path.lstat().st_mtime_ns, + ) + for path in sorted(root.rglob("*")) + if path.is_symlink() + } + + first = link_state(temp_root / "journal") + + subprocess.run( + ["make", "skills"], + cwd=temp_root, + check=True, + capture_output=True, + text=True, + ) + + second = link_state(temp_root / "journal") + assert first == second + + +def test_skill_discovery_from_journal_cwd(): + repo_root = _repo_root() + skill_path = repo_root / "journal" / ".claude" / "skills" / "journal" / "SKILL.md" + + assert skill_path.is_file() + assert skill_path.read_text(encoding="utf-8").startswith("---")