Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 6 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/stale.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/documentation-index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
17 changes: 4 additions & 13 deletions infrastructure/core/cache_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"

Expand Down
3 changes: 2 additions & 1 deletion infrastructure/core/pipeline/multi_project_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
13 changes: 12 additions & 1 deletion infrastructure/core/project_paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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"]
5 changes: 3 additions & 2 deletions infrastructure/doctor/safety.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 4 additions & 3 deletions infrastructure/orchestration/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -49,10 +50,10 @@ def _default_repo_root() -> Path:
"""Locate the repository root.

The orchestration package lives at
``<repo_root>/infrastructure/orchestration/``, so the repo root is two
parents up from this file.
``<repo_root>/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:
Expand Down
3 changes: 2 additions & 1 deletion infrastructure/project/linking.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion infrastructure/publishing/metadata_export_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions infrastructure/validation/docs/public_audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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+"
Expand All @@ -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"
Expand All @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion infrastructure/validation/integrity/check_links.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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)


Expand Down
17 changes: 17 additions & 0 deletions tests/infra_tests/core/test_project_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
2 changes: 2 additions & 0 deletions tests/infra_tests/validation/docs/test_consistency_lint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)