diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a3869da21..3a5ec051c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -22,10 +22,13 @@ # top-level instruction-hierarchy docs. /projects/templates/template_active_inference/ @docxology /projects/templates/template_autoresearch_project/ @docxology +/projects/templates/template_autoscientists/ @docxology /projects/templates/template_code_project/ @docxology +/projects/templates/template_newspaper/ @docxology /projects/templates/template_prose_project/ @docxology /projects/templates/template_sia/ @docxology /projects/templates/template_template/ @docxology +/projects/templates/template_textbook/ @docxology /CLAUDE.md @docxology /AGENTS.md @docxology /README.md @docxology diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index db64c14d2..bcb752560 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,6 +13,12 @@ on: permissions: contents: write # Required to create the GitHub Release +# Serialize releases per ref; never cancel an in-flight release (a half-cancelled +# release is worse than a queued one). +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + jobs: release: name: Create GitHub Release diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index c659900a1..6931a72f8 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -10,6 +10,11 @@ permissions: issues: write pull-requests: write +# Only one stale sweep at a time; a superseded sweep is safe to cancel. +concurrency: + group: stale + cancel-in-progress: true + jobs: stale: name: Mark and Close Stale Items diff --git a/docs/core/README.md b/docs/core/README.md index 1c7ac94ad..03053d8f3 100644 --- a/docs/core/README.md +++ b/docs/core/README.md @@ -15,6 +15,7 @@ The `core/` directory contains the essential documentation that all users should | [`how-to-use.md`](how-to-use.md) | usage guide from basic to advanced (all 12 levels) | New users, developers | | [`architecture.md`](architecture.md) | System design and structure overview | Developers, architects | | [`workflow.md`](workflow.md) | Development workflow and best practices | Developers | +| [`literature-data-flow.md`](literature-data-flow.md) | Literature-search data pipeline and reference flow | Developers, researchers | ## Quick Navigation diff --git a/docs/documentation-index.md b/docs/documentation-index.md index c806cab4b..70a32a216 100644 --- a/docs/documentation-index.md +++ b/docs/documentation-index.md @@ -41,7 +41,7 @@ This index lists documentation files in the Research Project Template by categor --- > [!IMPORTANT] -> **Multi-project pipeline pitfalls** (root venv deps, `matplotlib` in core deps, `project_config:` namespace, idempotency) — authoritative copy in [docs/AGENTS.md](AGENTS.md#learnings--known-issues) and [guides/new-project-setup.md](guides/new-project-setup.md#pitfall-6-root-venv). +> **Multi-project pipeline pitfalls** (root venv deps, `matplotlib` in core deps, `project_config:` namespace, idempotency) — authoritative copy in [docs/AGENTS.md](AGENTS.md#learnings--known-issues) and [guides/new-project-setup.md](guides/new-project-setup.md#pitfall-6-project-specific-packages-absent-from-root-venv--silent-stage-4-failure). ## Topic routing (canonical → deep dives) diff --git a/infrastructure/core/cache_gate.py b/infrastructure/core/cache_gate.py index e345c1a93..d260cb2ee 100644 --- a/infrastructure/core/cache_gate.py +++ b/infrastructure/core/cache_gate.py @@ -13,7 +13,6 @@ import time from datetime import datetime, timezone from pathlib import Path -from typing import Any logger = logging.getLogger(__name__) @@ -78,17 +77,6 @@ def observe(self, value: float) -> None: ) -def _load_plugin_manifest(hermes_home: Path) -> dict[str, Any] | None: - manifest_path = hermes_home / "plugins" / "manifest.json" - if not manifest_path.exists(): - return None - try: - return json.loads(manifest_path.read_text(encoding="utf-8")) - except json.JSONDecodeError as exc: - print(f"ERROR: Invalid plugin manifest JSON: {exc}") - return None - - def _is_cache_fresh(cache_marker: Path, ttl_seconds: int) -> bool: if not cache_marker.exists(): return False @@ -133,7 +121,10 @@ def _populate_cache(hermes_home: Path, cache_path: Path, cache_marker: Path) -> def run_cache_gate() -> int: hermes_home_env = os.getenv("HERMES_HOME") - cache_path = Path(os.getenv("HERMES_CACHE_PATH", "/var/cache/template")) + # Default under the user cache dir (XDG_CACHE_HOME or ~/.cache) for portability; + # /var/cache requires root and is not writable on most dev/CI machines. + default_cache = Path(os.getenv("XDG_CACHE_HOME") or (Path.home() / ".cache")) / "template" + cache_path = Path(os.getenv("HERMES_CACHE_PATH") or default_cache) cache_ttl = int(os.getenv("CACHE_TTL_SECONDS", "3600")) cache_marker = cache_path / ".cache_valid" diff --git a/infrastructure/core/pipeline/multi_project_cli.py b/infrastructure/core/pipeline/multi_project_cli.py index b862ef270..f9498f606 100644 --- a/infrastructure/core/pipeline/multi_project_cli.py +++ b/infrastructure/core/pipeline/multi_project_cli.py @@ -7,6 +7,7 @@ from pathlib import Path from infrastructure.core.logging.utils import get_logger, log_header, log_success +from infrastructure.core.project_paths import find_repo_root from infrastructure.core.pipeline.multi_project import ( MultiProjectConfig, MultiProjectOrchestrator, @@ -244,7 +245,7 @@ def build_arg_parser() -> argparse.ArgumentParser: def main(argv: list[str] | None = None, *, repo_root: Path | None = None) -> int: """CLI entry point.""" args = build_arg_parser().parse_args(argv) - root = repo_root or Path(__file__).resolve().parents[4] + root = repo_root or find_repo_root() if args.parallel: return execute_multi_project_parallel( diff --git a/infrastructure/core/project_paths.py b/infrastructure/core/project_paths.py index 0419e68f7..610c81cf0 100644 --- a/infrastructure/core/project_paths.py +++ b/infrastructure/core/project_paths.py @@ -21,6 +21,17 @@ NON_RENDERED_SUBDIRS: frozenset[str] = frozenset({"working", "published", "archive", "other"}) +def find_repo_root() -> Path: + """Return the repository root (the directory containing ``infrastructure/``). + + This module lives at ``infrastructure/core/project_paths.py``, so the repo + root is two parents up from this file. Centralising the computation here lets + callers stop hard-coding their own ``Path(__file__).resolve().parents[N]`` + depth, which is fragile under file moves and easy to get off by one. + """ + return Path(__file__).resolve().parents[2] + + def resolve_project_root(repo_root: Path | str, project_name: str) -> Path: """Return the directory for *project_name*, preferring the hot seat over WIP trees. @@ -81,4 +92,4 @@ def has_project_markers(path: Path) -> bool: return primary -__all__ = ["NON_RENDERED_SUBDIRS", "resolve_project_root"] +__all__ = ["NON_RENDERED_SUBDIRS", "find_repo_root", "resolve_project_root"] diff --git a/infrastructure/doctor/safety.py b/infrastructure/doctor/safety.py index dbefae3d9..0701a2a57 100644 --- a/infrastructure/doctor/safety.py +++ b/infrastructure/doctor/safety.py @@ -196,8 +196,9 @@ def _validate_paths(paths: Iterable[Path], state: DoctorState) -> list[Path]: doctor_root = state.root.resolve() for raw in paths: # Resolve symlinks and ``..`` segments but tolerate non-existent - # leaves (resolve(strict=False) still normalises). - resolved = Path(os.path.normpath(str((repo / raw).resolve()))) if not raw.is_absolute() else raw.resolve() + # leaves (resolve(strict=False) still normalises — the extra + # os.path.normpath wrapper was redundant). + resolved = (repo / raw).resolve() if not raw.is_absolute() else raw.resolve() try: resolved.relative_to(repo) except ValueError as exc: diff --git a/infrastructure/orchestration/cli.py b/infrastructure/orchestration/cli.py index a378612bf..057e232b7 100644 --- a/infrastructure/orchestration/cli.py +++ b/infrastructure/orchestration/cli.py @@ -21,6 +21,7 @@ from collections.abc import Sequence from pathlib import Path +from infrastructure.core.project_paths import find_repo_root from infrastructure.orchestration.discovery import ( discover_qualified_names, select_project_interactive, @@ -49,10 +50,10 @@ def _default_repo_root() -> Path: """Locate the repository root. The orchestration package lives at - ``/infrastructure/orchestration/``, so the repo root is two - parents up from this file. + ``/infrastructure/orchestration/``; delegate to the shared + ``find_repo_root`` helper rather than hard-coding the parent depth here. """ - return Path(__file__).resolve().parents[2] + return find_repo_root() def build_parser() -> argparse.ArgumentParser: diff --git a/infrastructure/project/linking.py b/infrastructure/project/linking.py index 435a0a989..281c0d5fa 100644 --- a/infrastructure/project/linking.py +++ b/infrastructure/project/linking.py @@ -47,6 +47,7 @@ from pathlib import Path from infrastructure.core.logging.utils import get_logger +from infrastructure.core.project_paths import find_repo_root from infrastructure.project.public_scope import PUBLIC_PROJECT_NAMES logger = get_logger(__name__) @@ -437,7 +438,7 @@ def _build_parser() -> argparse.ArgumentParser: def main(argv: list[str] | None = None) -> int: """CLI entry point. Returns process exit code (0 on success).""" ns = _build_parser().parse_args(argv) - repo_root = ns.repo_root or Path(__file__).resolve().parents[2] + repo_root = ns.repo_root or find_repo_root() result = sync_private_project_links( repo_root, ns.private_root, diff --git a/infrastructure/publishing/metadata_export_cli.py b/infrastructure/publishing/metadata_export_cli.py index ad8c5176f..cac6f9189 100644 --- a/infrastructure/publishing/metadata_export_cli.py +++ b/infrastructure/publishing/metadata_export_cli.py @@ -8,6 +8,7 @@ from pathlib import Path from infrastructure.core.config.loader import load_config +from infrastructure.core.project_paths import find_repo_root from infrastructure.project.discovery import resolve_project_root from infrastructure.publishing.metadata_export import write_metadata_files @@ -76,7 +77,7 @@ def main(argv: list[str] | None = None) -> int: def _resolve_repo_root(repo_root: Path | None) -> Path: if repo_root is not None: return repo_root.resolve() - derived = Path(__file__).resolve().parents[2] + derived = find_repo_root() if not (derived / "projects").is_dir(): raise ValueError(f"Could not determine repo root from {__file__}") return derived diff --git a/infrastructure/validation/docs/public_audit.py b/infrastructure/validation/docs/public_audit.py index 95b45aac1..28284824a 100644 --- a/infrastructure/validation/docs/public_audit.py +++ b/infrastructure/validation/docs/public_audit.py @@ -23,7 +23,11 @@ from infrastructure.validation.docs.lint_runner import doc_roots from infrastructure.validation.docs.scan_scope import DEFAULT_EXCLUDE_PARTS, iter_markdown_files, should_exclude_path +# Bare exemplar slugs like ``template_active_inference`` (used to spot hard-coded +# project names that should be sourced from the generated roster instead). _TEMPLATE_SLUG_RE = re.compile(r"\btemplate_[A-Za-z0-9_]+\b") +# Prose count claims about the exemplar roster, in either order ("nine public +# exemplars" / "exemplars ... 9"); flags literals that drift from canonical_facts. _PROJECT_COUNT_RE = re.compile( r"\b(?:\d+|nine|ten)\s+" r"(?:current|active|rendered|public|permanent|canonical|template)\s+" @@ -33,18 +37,24 @@ r"(?:projects|exemplars|templates)\b.{0,80}\b(?:\d+|nine|ten)\b", re.IGNORECASE, ) +# A reference to a generated-facts source (a count claim near one of these is +# considered sourced, not a hard-coded literal). _GENERATED_FACT_LINK_RE = re.compile( r"docs/_generated/(?:active_projects|canonical_facts|publication_records)\.md|" r"_generated/|PUBLIC_PROJECT_NAMES|generate_publication_records_doc\.py|" r"\$\{public_exemplar_list\}|\$\{project_count\}", re.I, ) +# Roster-context phrasing ("current/active/public set", "under projects/templates/") +# used to disambiguate count claims that are genuinely about the exemplar roster. _ROSTER_CONTEXT_RE = re.compile( r"\b(?:current|active|rendered|public|permanent|always-present|roster|set|all)\b|" r"under\s+`?projects/templates/?`?", re.IGNORECASE, ) +# Gate/validator enforcement claims ("the schema must validate ..."), in either +# noun→verb or verb→noun order; surfaces strong guarantees that need evidence. _GATE_CLAIM_RE = re.compile( r"\b(?:validator|verifier|schema|quality gate|gate|checker|linter|lint|rule)\b" r".{0,100}\b(?:must|requires?|enforces?|validates?|certifies?|proves?|guarantees?|blocks?|fails?)\b" @@ -53,6 +63,8 @@ r".{0,100}\b(?:validator|verifier|schema|quality gate|gate|checker|linter|lint|rule)\b", re.IGNORECASE, ) +# Negative-control / known-wrong terminology — evidence that a gate claim is +# backed by an adversarial test rather than asserted. _NEGATIVE_CONTROL_RE = re.compile( r"negative[- ]control|known[- ]wrong|counterexample|fault[- ]inject|expected[- ]fail|bad fixture", re.IGNORECASE, diff --git a/infrastructure/validation/integrity/check_links.py b/infrastructure/validation/integrity/check_links.py index e0cfaa5be..770ac2ff1 100755 --- a/infrastructure/validation/integrity/check_links.py +++ b/infrastructure/validation/integrity/check_links.py @@ -11,6 +11,7 @@ from pathlib import Path from infrastructure.core.logging.utils import get_logger +from infrastructure.core.project_paths import find_repo_root from infrastructure.validation.content.discovery import discover_markdown_files from infrastructure.validation.docs.accuracy import extract_headings from infrastructure.validation.integrity.link_extract import ( @@ -62,7 +63,7 @@ def run_link_audit(repo_root: Path) -> int: def main() -> int: """Check all documentation links and references comprehensively.""" - repo_root = Path(__file__).resolve().parents[3] + repo_root = find_repo_root() return run_link_audit(repo_root) diff --git a/tests/infra_tests/core/test_project_discovery.py b/tests/infra_tests/core/test_project_discovery.py index ecc01c463..e952fff67 100644 --- a/tests/infra_tests/core/test_project_discovery.py +++ b/tests/infra_tests/core/test_project_discovery.py @@ -8,12 +8,29 @@ from pathlib import Path +from infrastructure.core.project_paths import find_repo_root from infrastructure.project.project_info import ProjectInfo from infrastructure.project.metadata import get_project_metadata from infrastructure.project.validation import validate_project_structure from infrastructure.project.discovery import discover_projects +def test_find_repo_root_points_at_repository_root() -> None: + """find_repo_root() resolves to the directory containing infrastructure/ and projects/. + + Several CLIs route their repo-root fallback through this helper instead of + hard-coding Path(__file__).resolve().parents[N]; this pins the contract. + """ + root = find_repo_root() + assert (root / "infrastructure").is_dir() + assert (root / "projects").is_dir() + assert (root / "pyproject.toml").is_file() + # Equivalent to the historic computation from this module's location. + from infrastructure.core import project_paths + + assert root == Path(project_paths.__file__).resolve().parents[2] + + class TestValidateProjectStructure: """Test project structure validation.""" diff --git a/tests/infra_tests/validation/docs/test_consistency_lint.py b/tests/infra_tests/validation/docs/test_consistency_lint.py index 05dc2413c..9f5266fd5 100644 --- a/tests/infra_tests/validation/docs/test_consistency_lint.py +++ b/tests/infra_tests/validation/docs/test_consistency_lint.py @@ -12,5 +12,7 @@ def test_consistency_lint_facade_exports_check_functions() -> None: "check_readme_files_list", "check_canonical_count_singularity", "check_stale_shell_contracts", + "check_memory_decision_rule_links", + "check_project_discovery_claims", ): assert hasattr(consistency_lint, name)