diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ddf2a4f..7ee1208 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,8 +14,85 @@ concurrency: cancel-in-progress: true jobs: + changes: + name: Changed surfaces + runs-on: ubuntu-22.04 + outputs: + harness: ${{ steps.classify.outputs.harness }} + docs: ${{ steps.classify.outputs.docs }} + python: ${{ steps.classify.outputs.python }} + rust: ${{ steps.classify.outputs.rust }} + package: ${{ steps.classify.outputs.package }} + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Classify changed paths + id: classify + env: + BASE: ${{ github.event.pull_request.base.sha || github.event.before }} + HEAD: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + python scripts/agent/ci_changes.py "$BASE" "$HEAD" + git diff --check "$BASE" "$HEAD" + + harness: + name: Agent harness + needs: changes + if: ${{ needs.changes.outputs.harness == 'true' }} + runs-on: ubuntu-22.04 + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up uv + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 + with: + enable-cache: true + python-version: "3.11" + + - name: Install locked harness environment + run: uv sync --locked --only-group dev --no-install-project + + - name: Lint harness + run: uv run --no-sync ruff check scripts/agent tests/test_agent_*.py tests/test_rtk_hook.py tests/test_codegraph_mcp.py + + - name: Check harness formatting + run: uv run --no-sync ruff format --check scripts/agent tests/test_agent_*.py tests/test_rtk_hook.py tests/test_codegraph_mcp.py + + - name: Test harness + run: uv run --no-sync pytest -q tests/test_agent_*.py tests/test_rtk_hook.py tests/test_codegraph_mcp.py + + docs: + name: Documentation + needs: changes + if: ${{ needs.changes.outputs.docs == 'true' }} + runs-on: ubuntu-22.04 + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up uv + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 + with: + enable-cache: true + python-version: "3.11" + + - name: Install locked docs test environment + run: uv sync --locked --only-group dev --no-install-project + + - name: Check local links + run: uv run --no-sync python scripts/check_local_links.py + + - name: Check documentation routes + run: uv run --no-sync pytest -q tests/test_docs_routes.py + python: name: Python / package + needs: changes + if: ${{ needs.changes.outputs.python == 'true' }} runs-on: ubuntu-22.04 steps: - name: Check out repository @@ -30,7 +107,6 @@ jobs: uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 with: enable-cache: true - version-file: uv.toml - name: Install locked development environment run: uv sync --locked --all-groups @@ -43,6 +119,8 @@ jobs: rust: name: Rust + needs: changes + if: ${{ needs.changes.outputs.rust == 'true' }} runs-on: ubuntu-22.04 steps: - name: Check out repository @@ -62,13 +140,14 @@ jobs: uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 with: enable-cache: true - version-file: uv.toml - name: Run Rust validation run: uv run --no-project python scripts/agent/check.py rust package: name: Package / release wheel + needs: changes + if: ${{ needs.changes.outputs.package == 'true' }} runs-on: ubuntu-22.04 steps: - name: Check out repository @@ -83,7 +162,33 @@ jobs: uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 with: enable-cache: true - version-file: uv.toml - name: Build, inspect, and smoke test release artifacts run: uv run --locked python scripts/agent/check.py package --smoke + + gate: + name: CI gate + needs: [changes, harness, docs, python, rust, package] + if: ${{ always() }} + runs-on: ubuntu-22.04 + steps: + - name: Require all selected checks + env: + CHANGES: ${{ needs.changes.result }} + HARNESS_REQUIRED: ${{ needs.changes.outputs.harness }} + HARNESS_RESULT: ${{ needs.harness.result }} + DOCS_REQUIRED: ${{ needs.changes.outputs.docs }} + DOCS_RESULT: ${{ needs.docs.result }} + PYTHON_REQUIRED: ${{ needs.changes.outputs.python }} + PYTHON_RESULT: ${{ needs.python.result }} + RUST_REQUIRED: ${{ needs.changes.outputs.rust }} + RUST_RESULT: ${{ needs.rust.result }} + PACKAGE_REQUIRED: ${{ needs.changes.outputs.package }} + PACKAGE_RESULT: ${{ needs.package.result }} + run: | + test "$CHANGES" = success + if [ "$HARNESS_REQUIRED" = true ]; then test "$HARNESS_RESULT" = success; fi + if [ "$DOCS_REQUIRED" = true ]; then test "$DOCS_RESULT" = success; fi + if [ "$PYTHON_REQUIRED" = true ]; then test "$PYTHON_RESULT" = success; fi + if [ "$RUST_REQUIRED" = true ]; then test "$RUST_RESULT" = success; fi + if [ "$PACKAGE_REQUIRED" = true ]; then test "$PACKAGE_RESULT" = success; fi diff --git a/AGENTS.md b/AGENTS.md index dd6fc14..518ef15 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,6 +5,9 @@ voice-agent transcripts. Rust generates valid written candidates, a frozen DeBERTa scorer ranks them in sentence context, and an exact decoder selects compatible edits. +Code and tests define implemented behavior. Read the README only for public API, +installation, or documentation changes. + ## Boundaries - `rust/` owns deterministic realization rules and candidate generation. @@ -15,11 +18,19 @@ compatible edits. ## Change rules -- For semantic implementation changes, read the relevant implementation, - direct callers, and tests before editing. For small mechanical or - configuration changes, inspect only the target state and evidence needed to - apply and validate the requested change. -- Make the smallest coherent change and preserve unrelated work. +- For semantic changes, inspect the implementation, direct callers, consumers, + and relevant tests. +- For mechanical changes, inspect only the target and required validation. +- Use CodeGraph for structure. Use `rg` for exact text and references. +- Make the smallest coherent change. Preserve unrelated work. +- Add abstractions, options, compatibility layers, or caches only for a current + need. +- Keep one authoritative owner for each semantic rule. +- Test behavior, not edits. Add or update tests only when behavior, contracts, + or critical workflow logic changes. +- Prefer deterministic tests and replay. Use live models or downloads only when + that boundary is under test. +- Measure end-to-end latency before claiming a performance improvement. - Keep `PremoveITN` as the single implementation behind the Python API and CLI. - Do not change model, candidate, decoder, or normalization behavior in a cleanup-only change. @@ -74,12 +85,21 @@ If two evidence-driven fixes fail and the cause remains unclear, stop and return ## Git - Treat `main` as the stable release branch. -- Use one short-lived branch and one focused pull request per change. +- Create branches with `scripts/agent/start_change.py --base --branch /`. Use `main` for normal work and the parent branch for stacked work. +- Use prefixes: `feat/`, `fix/`, `refactor/`, `perf/`, `test/`, `docs/`, `ci/`, `build/`, `chore/`, `design/`, and `epic/`. +- Keep each pull request to one coherent responsibility and one primary review question. Keep its implementation, tests, and required docs together. +- Split at a new responsibility, pipeline boundary, independent invariant, or substantial runtime surface. File count is a warning, not a limit. +- Use stacked pull requests for dependent stages. Each layer targets its parent branch. +- Use `epic/` only when dependent work must stay off `main` until integrated. Child PRs target the epic; the final epic PR targets `main`. +- Do not split trivial work or combine unrelated work for PR-count or file-count reasons. - Do not push directly to `main` or force-push shared branches. -- Merge only after required checks pass. +- Merge only when explicitly requested and required checks pass. ## Verification +Keep validation proportional to the changed surface. Documentation and policy-only +changes do not require the full Python, Rust, or package suites. + During implementation, use the focused gate with the semantic tests relevant to the change: @@ -87,10 +107,6 @@ the change: uv run --locked python scripts/agent/check.py focused --pytest ``` -Prefer the smallest validation that establishes the change locally. Do not run -the complete local gate only to duplicate checks that pull-request CI runs from -a clean checkout. - Use the complete local gate when the change is cross-cutting, modifies the validation harness or CI, prepares a release, CI is unavailable, or focused evidence is insufficient: @@ -100,6 +116,7 @@ uv run --locked python scripts/agent/check.py full ``` GitHub Actions is the authoritative completion gate for pull requests. + Do not repeat an already-passing local gate only to open or integrate an unchanged, already-reviewed branch. Confirm the branch state and expected diff, then rely on the target pull request's authoritative CI unless prior validation @@ -107,21 +124,22 @@ is stale, incomplete, or the integration introduces new changes. ## Command output -RTK may compact supported shell output. When compact output omits information -needed to continue: +RTK may compact shell output. -1. Use the printed `rtk recall ` handle. -2. Use `rtk proxy ` if raw re-execution is required. -3. Use `RTK_DISABLED=1 ` only when RTK behavior itself is under - investigation. +- Use `rtk recall ` when omitted output is needed. +- Use `rtk proxy ` for raw re-execution. +- Use `RTK_DISABLED=1` only when debugging RTK. +- Do not wrap `scripts/agent/check.py` with RTK. It already verifies exact recall. - ## CodeGraph -In repositories indexed by CodeGraph (a `.codegraph/` directory exists at the repo root), reach for it BEFORE grep/find or reading files when you need to understand or locate code: +Use CodeGraph before broad grep, find, or file reads when locating or +understanding code. -- **MCP tool** (when available): `codegraph_explore` answers most code questions in one call — the relevant symbols' verbatim source plus the call paths between them, including dynamic-dispatch hops grep can't follow. Name a file or symbol in the query to read its current line-numbered source. If it's listed but deferred, load it by name via tool search. -- **Shell** (always works): `codegraph explore ""` prints the same output. +Use `rg` for exact text and references. -If there is no `.codegraph/` directory, skip CodeGraph entirely — indexing is the user's decision. - +If `.codegraph/` is missing, run: + +```bash +uv run --locked python scripts/agent/bootstrap.py +``` diff --git a/AGENT_HARNESS.md b/AGENT_HARNESS.md index 1bf881f..e313fad 100644 --- a/AGENT_HARNESS.md +++ b/AGENT_HARNESS.md @@ -68,7 +68,8 @@ repository's `scripts/agent/rtk_hook.py` adapter. RTK shortens supported shell output and preserves a way to recover the full output. When a compact result omits needed detail, use `rtk recall `. Use `rtk proxy ` to rerun a command with raw output. Set `RTK_DISABLED=1` only when investigating -RTK itself. +RTK itself. The repository-owned validation runner must not be outer-wrapped by +RTK because it already verifies exact recall before compacting failures. ## Validation @@ -85,3 +86,8 @@ enough: ```bash uv run --locked python scripts/agent/check.py full ``` + +Pull-request CI classifies changed paths before running expensive work. Agent +harness, documentation, Python, Rust, and package checks run only when their +surfaces require them. Unknown surfaces select the full set. A final CI gate +requires every selected job and is the authoritative pull-request result. diff --git a/scripts/agent/ci_changes.py b/scripts/agent/ci_changes.py new file mode 100644 index 0000000..c79ebdc --- /dev/null +++ b/scripts/agent/ci_changes.py @@ -0,0 +1,120 @@ +"""Classify changed paths for the conditional CI workflow.""" + +from __future__ import annotations + +import os +import subprocess +import sys + +_HARNESS_FILES = {"tests/test_rtk_hook.py", "tests/test_codegraph_mcp.py"} +_DOC_FILES = { + "README.md", + "CONTRIBUTING.md", + "docs.json", + "index.mdx", + "style.css", + "site.js", + "premove-icon.png", + "scripts/check_local_links.py", + "tests/test_docs_routes.py", +} +_POLICY_FILES = { + "AGENTS.md", + "AGENT_HARNESS.md", + "CHANGELOG.md", + "LICENSE", + "THIRD_PARTY_NOTICES.md", + ".github/PULL_REQUEST_TEMPLATE.md", +} +_POLICY_PREFIXES = ( + "LICENSES/", + ".github/PULL_REQUEST_TEMPLATE/", + ".github/ISSUE_TEMPLATE/", +) + + +def _is_harness_path(path: str) -> bool: + if path.startswith((".codex/", "scripts/agent/")): + return True + if path in _HARNESS_FILES: + return True + return path.startswith("tests/test_agent_") and path.endswith(".py") + + +def _is_docs_path(path: str) -> bool: + if path in _DOC_FILES: + return True + if path.startswith(("docs/", "itn/")): + return True + return path.startswith(("benchmarks/", "eval/")) and path.endswith((".md", ".mdx")) + + +def _is_policy_path(path: str) -> bool: + if path in _POLICY_FILES: + return True + return path.startswith(_POLICY_PREFIXES) + + +def classify(paths: list[str]) -> tuple[bool, bool, bool, bool, bool]: + """Return whether harness, docs, Python, Rust, and package checks are required.""" + harness = docs = python = rust = package = False + + for path in paths: + if ( + path == "scripts/agent/ci_changes.py" + or path.startswith(".github/workflows/") + or path == "uv.toml" + ): + harness = docs = python = rust = package = True + elif path == "uv.lock": + harness = docs = python = package = True + elif path == "pyproject.toml": + docs = python = package = True + elif path.startswith("rust/"): + python = rust = package = True + elif _is_harness_path(path): + harness = True + elif _is_docs_path(path): + docs = True + elif ( + path.startswith(("src/", "tests/")) + or path == "scripts/check_frozen_boundaries.py" + ): + python = True + elif path == "scripts/inspect_release_artifact.py": + package = True + elif _is_policy_path(path): + continue + else: + # A new surface gets the full suite until it is classified. + harness = docs = python = rust = package = True + + return harness, docs, python, rust, package + + +def main() -> int: + if len(sys.argv) != 3: + print("usage: ci_changes.py BASE HEAD", file=sys.stderr) + return 2 + + paths = ( + subprocess.check_output( + ["git", "diff", "--name-only", "-z", sys.argv[1], sys.argv[2]] + ) + .decode() + .split("\0") + ) + required = classify([path for path in paths if path]) + + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: + for name, value in zip( + ("harness", "docs", "python", "rust", "package"), + required, + strict=True, + ): + print(f"{name}={str(value).lower()}", file=output) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_agent_ci_changes.py b/tests/test_agent_ci_changes.py new file mode 100644 index 0000000..dcdea88 --- /dev/null +++ b/tests/test_agent_ci_changes.py @@ -0,0 +1,89 @@ +"""Changed-surface routing keeps required CI checks proportional.""" + +from scripts.agent.ci_changes import classify + + +def test_policy_only_needs_no_expensive_jobs() -> None: + assert classify( + [ + "AGENTS.md", + "AGENT_HARNESS.md", + "CHANGELOG.md", + ".github/PULL_REQUEST_TEMPLATE.md", + ".github/ISSUE_TEMPLATE/bug_report.md", + ] + ) == (False, False, False, False, False) + + +def test_harness_only_needs_harness_job() -> None: + assert classify(["tests/test_agent_ci_changes.py"]) == ( + True, + False, + False, + False, + False, + ) + assert classify( + [ + ".codex/config.toml", + "scripts/agent/start_change.py", + "tests/test_agent_check.py", + "tests/test_rtk_hook.py", + "tests/test_codegraph_mcp.py", + ] + ) == (True, False, False, False, False) + + +def test_docs_use_lightweight_docs_job() -> None: + assert classify( + [ + "README.md", + "docs/model-card.md", + "itn/docs/deployment.md", + "docs.json", + "style.css", + "site.js", + "tests/test_docs_routes.py", + ] + ) == (False, True, False, False, False) + + +def test_python_runtime_does_not_force_rust_or_package_jobs() -> None: + assert classify(["src/premove_itn/contextual.py"]) == ( + False, + False, + True, + False, + False, + ) + assert classify(["tests/test_candidate_scorer.py"]) == ( + False, + False, + True, + False, + False, + ) + + +def test_rust_changes_cover_cross_language_and_package_behavior() -> None: + assert classify(["rust/src/lib.rs"]) == (False, False, True, True, True) + assert classify(["rust/Cargo.toml"]) == (False, False, True, True, True) + + +def test_dependency_routes_match_consumers() -> None: + assert classify(["uv.lock"]) == (True, True, True, False, True) + assert classify(["pyproject.toml"]) == (False, True, True, False, True) + assert classify(["uv.toml"]) == (True, True, True, True, True) + + +def test_workflow_and_unknown_paths_require_all_jobs() -> None: + assert classify(["scripts/agent/ci_changes.py"]) == (True, True, True, True, True) + assert classify([".github/workflows/ci.yml"]) == (True, True, True, True, True) + assert classify(["new-surface/tool.py"]) == (True, True, True, True, True) + assert classify(["AGENTS.md", "src/premove_itn/contextual.py"]) == ( + False, + False, + True, + False, + False, + )