diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a0b3702 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,62 @@ +name: ci + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + name: lint & format (ruff) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install dev dependencies + run: pip install -e ".[dev]" + - name: ruff check + run: ruff check . + - name: ruff format --check + run: ruff format --check . + + test: + name: test (py${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install package (with dev extras) + run: pip install -e ".[dev]" + - name: Run test suite + run: pytest -q + - name: Install optional extras and re-run + run: | + pip install -e ".[dev,lsp,bench,mcp]" || echo "optional extras unavailable; skipping extra run" + pytest -q + + consistency: + name: cross-repo consistency (non-blocking) + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install package + run: pip install -e ".[dev]" + - name: Check consistency + run: python scripts/check_consistency.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 760ffb0..4ed9b02 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,13 +5,13 @@ on: tags: ["v*"] permissions: - contents: read - id-token: write # required for PyPI Trusted Publishing (no API token needed) + contents: write # required to create the GitHub Release + upload assets + id-token: write # required for PyPI Trusted Publishing (no API token needed) jobs: - build-and-publish: + build: + name: build sdist + wheel runs-on: ubuntu-latest - environment: pypi steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 @@ -19,7 +19,43 @@ jobs: python-version: "3.12" - name: Build distribution run: | - python -m pip install --upgrade build hatchling + python -m pip install --upgrade build python -m build + - name: Verify wheel installs in a clean env + run: | + python -m venv /tmp/clean + /tmp/clean/bin/pip install dist/*.whl + /tmp/clean/bin/sin --help >/dev/null + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/* + + github-release: + name: attach artifacts to GitHub Release + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: dist/* + generate_release_notes: true + + pypi-publish: + name: publish to PyPI (Trusted Publishing) + needs: build + runs-on: ubuntu-latest + environment: pypi + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 52e94a1..7550a33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Operational hardening** (closes #8): production-readiness CI/release tooling. + - `.github/workflows/ci.yml`: `ruff check` + `ruff format --check` lint gate + and a `pytest` matrix across Python 3.11/3.12/3.13, plus a non-blocking + cross-repo consistency job. + - `.github/workflows/release.yml`: builds sdist+wheel on `v*` tags, verifies a + clean-env install, attaches artifacts to a GitHub Release, and publishes to + PyPI via Trusted Publishing. + - `scripts/check_consistency.py` (WS4): asserts version alignment, subsystem + import health, and that every `sin mcp-config` client points at the real + `sin serve` entry point. `--strict` mode for full multi-repo CI. + - `scripts/dev_install.sh` + `scripts/run_all_tests.sh` (WS5): two-command + editable bootstrap and aggregated test runner across all 8 sibling repos. + - Adopted a shared `ruff` config (E/F/I/W) and applied a one-shot mechanical + format; aligned `__version__` with the packaged `0.2.0`. - **GitNexus bridge** (`sin_code_bundle.gitnexus`): integrates the upstream [GitNexus](https://github.com/abhigyanpatwari/GitNexus) code knowledge graph as a mandatory, always-on context source for coder agents. GitNexus is diff --git a/docs/plans/operational-hardening.md b/docs/plans/operational-hardening.md index 19fe63d..7b1383b 100644 --- a/docs/plans/operational-hardening.md +++ b/docs/plans/operational-hardening.md @@ -1,6 +1,6 @@ # Plan: Operational Hardening -Status: proposed +Status: implemented (Bundle) Owner: unassigned Scope: all 7 SIN-Code repositories (SCKG, IBD, POC, EFSM, ADW, Verification-Oracle, Bundle) diff --git a/pyproject.toml b/pyproject.toml index ed80177..b31e07f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,3 +81,9 @@ testpaths = ["tests"] [tool.ruff] line-length = 100 target-version = "py311" +# CoDocs example fixtures demonstrate doc co-location, not runnable code. +extend-exclude = ["examples", "build", "dist"] + +[tool.ruff.lint] +select = ["E", "F", "I", "W"] +ignore = ["E501"] diff --git a/scripts/check_consistency.py b/scripts/check_consistency.py new file mode 100755 index 0000000..ab71506 --- /dev/null +++ b/scripts/check_consistency.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +"""Cross-repo consistency check for the SIN-Code Bundle (WS4 of operational-hardening). + +The Bundle orchestrates 8 sibling subsystems that are installed via local +``pip install -e`` of adjacent repos. This script asserts that the Bundle's +own expectations stay internally consistent and reports drift against any +subsystems that happen to be installed. + +Design goals: +- Exit 0 on a clean *bundle-only* checkout (subsystems absent -> warnings, not + failures), so it is safe to wire into CI as a non-blocking job first. +- Promote ``--strict`` to make any missing subsystem or mismatch fail (exit 1), + for use once the full multi-repo environment is provisioned. + +Checks performed: +1. Bundle metadata: ``pyproject`` version == ``__init__.__version__``. +2. Subsystem import specs: each subsystem the ``status`` command probes either + imports cleanly or is reported as not-installed. +3. MCP advertising: every client config emitted by ``sin mcp-config`` points at + the same ``sin serve`` entry point that the package actually registers. +""" + +from __future__ import annotations + +import argparse +import importlib.metadata as md +import importlib.util +import sys +import tomllib +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent + +# Canonical subsystem map -- kept in sync with cli.status(). +SUBSYSTEMS = { + "sin_code_sckg": "SCKG (knowledge graph)", + "sin_code_ibd": "IBD (intent diff)", + "sin_code_poc": "POC (proof of correctness)", + "sin_code_efsm": "EFSM (mock orchestration)", + "sin_code_adw": "ADW (debt watchdog)", + "sin_code_oracle": "Oracle (verification)", + "sin_code_orchestration": "Orchestration (multi-agent workflow)", + "sin_code_review_interface": "Review-Interface (semantic review UI)", +} + +GREEN, YELLOW, RED, RESET = "\033[32m", "\033[33m", "\033[31m", "\033[0m" + + +def _ok(msg: str) -> None: + print(f"{GREEN}OK{RESET} {msg}") + + +def _warn(msg: str) -> None: + print(f"{YELLOW}WARN{RESET} {msg}") + + +def _fail(msg: str) -> None: + print(f"{RED}FAIL{RESET} {msg}") + + +def check_version() -> list[str]: + errors: list[str] = [] + pyproject = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text()) + declared = pyproject["project"]["version"] + init_text = (REPO_ROOT / "src" / "sin_code_bundle" / "__init__.py").read_text() + runtime = next( + ( + line.split("=", 1)[1].strip().strip('"').strip("'") + for line in init_text.splitlines() + if line.startswith("__version__") + ), + None, + ) + if runtime == declared: + _ok(f"version aligned: pyproject == __init__ == {declared}") + else: + _fail(f"version drift: pyproject={declared!r} but __init__={runtime!r}") + errors.append("version drift") + return errors + + +def check_subsystems(strict: bool) -> list[str]: + errors: list[str] = [] + for module, desc in SUBSYSTEMS.items(): + installed = importlib.util.find_spec(module) is not None + if installed: + try: + version = md.version(module.replace("_", "-")) + except md.PackageNotFoundError: + version = "unknown" + _ok(f"{desc}: importable (v{version})") + elif strict: + _fail(f"{desc}: module '{module}' not installed (strict)") + errors.append(f"{module} missing") + else: + _warn(f"{desc}: module '{module}' not installed (expected in bundle-only checkout)") + return errors + + +def check_mcp_advertising() -> list[str]: + errors: list[str] = [] + from sin_code_bundle import mcp_config + + expected_cmd, expected_args = mcp_config.COMMAND, mcp_config.ARGS + if (expected_cmd, expected_args) != ("sin", ["serve"]): + _fail(f"mcp entry point unexpected: {expected_cmd} {expected_args}") + errors.append("mcp entry point") + return errors + + # The package must actually expose the `sin` console script the configs point at. + scripts = {ep.name: ep.value for ep in md.entry_points(group="console_scripts")} + if scripts.get("sin", "").startswith("sin_code_bundle.cli"): + _ok("'sin' console script resolves to sin_code_bundle.cli") + else: + _fail(f"'sin' console script missing or wrong: {scripts.get('sin')!r}") + errors.append("console script") + + for client in mcp_config.SUPPORTED_CLIENTS: + rendered = mcp_config.generate(client) + if expected_cmd in rendered and "serve" in rendered: + _ok(f"mcp-config[{client}] advertises '{expected_cmd} serve'") + else: + _fail(f"mcp-config[{client}] does not advertise the serve entry point") + errors.append(f"mcp-config {client}") + return errors + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--strict", + action="store_true", + help="Treat missing subsystems as failures (full multi-repo env).", + ) + args = parser.parse_args() + + print("== SIN-Code Bundle consistency check ==") + errors: list[str] = [] + errors += check_version() + errors += check_subsystems(args.strict) + errors += check_mcp_advertising() + + print() + if errors: + _fail(f"{len(errors)} consistency problem(s): {', '.join(errors)}") + return 1 + _ok("all consistency checks passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/dev_install.sh b/scripts/dev_install.sh new file mode 100755 index 0000000..15ec680 --- /dev/null +++ b/scripts/dev_install.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# WS5: one-command editable dev setup for the full SIN-Code stack. +# +# Clones (if missing) and `pip install -e` each sibling subsystem next to this +# repo, then installs the Bundle itself with dev extras. Run from anywhere. +# +# ./scripts/dev_install.sh # clone missing repos + editable install +# SIN_NO_CLONE=1 ./scripts/dev_install.sh # only install repos already present +set -euo pipefail + +BUNDLE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WORKSPACE="$(cd "${BUNDLE_DIR}/.." && pwd)" +ORG="https://github.com/OpenSIN-Code" + +# Sibling repos in install order (dependencies before the bundle). +REPOS=( + "SIN-Code-Semantic-Codebase-Knowledge-Graphs" + "SIN-Code-Intent-Based-Diffing" + "SIN-Code-Proof-of-Correctness" + "SIN-Code-Ephemeral-Full-Stack-Mocking-Orchestration" + "SIN-Code-Architectural-Debt-Watchdogs" + "SIN-Code-Verification-Oracle" + "SIN-Code-Orchestration" + "SIN-Code-Review-Interface" +) + +echo "== SIN-Code dev install ==" +echo "workspace: ${WORKSPACE}" + +for repo in "${REPOS[@]}"; do + path="${WORKSPACE}/${repo}" + if [[ ! -d "${path}" ]]; then + if [[ "${SIN_NO_CLONE:-0}" == "1" ]]; then + echo "SKIP ${repo} (not present; SIN_NO_CLONE=1)" + continue + fi + echo "CLONE ${repo}" + git clone --depth 1 "${ORG}/${repo}.git" "${path}" + fi + echo "INSTALL ${repo}" + pip install -e "${path}" +done + +echo "INSTALL SIN-Code-Bundle [dev]" +pip install -e "${BUNDLE_DIR}[dev]" + +echo "== done. run 'sin status' to verify subsystems ==" diff --git a/scripts/run_all_tests.sh b/scripts/run_all_tests.sh new file mode 100755 index 0000000..73f54db --- /dev/null +++ b/scripts/run_all_tests.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# WS5: iterate every SIN-Code repo present next to the Bundle, run its test +# suite, and aggregate pass/fail results. Exits non-zero if any repo fails. +set -uo pipefail + +BUNDLE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WORKSPACE="$(cd "${BUNDLE_DIR}/.." && pwd)" + +REPOS=( + "SIN-Code-Semantic-Codebase-Knowledge-Graphs" + "SIN-Code-Intent-Based-Diffing" + "SIN-Code-Proof-of-Correctness" + "SIN-Code-Ephemeral-Full-Stack-Mocking-Orchestration" + "SIN-Code-Architectural-Debt-Watchdogs" + "SIN-Code-Verification-Oracle" + "SIN-Code-Orchestration" + "SIN-Code-Review-Interface" + "SIN-Code-Bundle" +) + +declare -a RESULTS +overall=0 + +for repo in "${REPOS[@]}"; do + path="${WORKSPACE}/${repo}" + [[ "${repo}" == "SIN-Code-Bundle" ]] && path="${BUNDLE_DIR}" + if [[ ! -d "${path}" ]]; then + RESULTS+=("SKIP ${repo} (not present)") + continue + fi + echo "== ${repo} ==" + if (cd "${path}" && pytest -q); then + RESULTS+=("PASS ${repo}") + else + RESULTS+=("FAIL ${repo}") + overall=1 + fi +done + +echo +echo "== aggregate results ==" +printf '%s\n' "${RESULTS[@]}" +exit "${overall}" diff --git a/src/sin_code_bundle/__init__.py b/src/sin_code_bundle/__init__.py index 77ade3e..e63904f 100644 --- a/src/sin_code_bundle/__init__.py +++ b/src/sin_code_bundle/__init__.py @@ -1,2 +1,3 @@ """SIN-Code Bundle - Unified SOTA Agent-Engineering Stack.""" -__version__ = "0.1.0" + +__version__ = "0.2.0" diff --git a/src/sin_code_bundle/agents_md.py b/src/sin_code_bundle/agents_md.py index beaae48..5595ddb 100644 --- a/src/sin_code_bundle/agents_md.py +++ b/src/sin_code_bundle/agents_md.py @@ -5,6 +5,7 @@ welches SIN-Tool aufzurufen ist. Der Block ist zwischen Markern eingefasst und wird idempotent ersetzt -- der restliche Inhalt der Datei bleibt unangetastet. """ + from __future__ import annotations from pathlib import Path @@ -14,23 +15,82 @@ # Mapping: wann welches Tool. Bewusst knapp und handlungsorientiert. _PLAYBOOK = [ - ("Before refactoring or deleting a symbol", "impact", - "Get the blast radius (downstream dependents) before you change a shared symbol."), - ("After producing a diff / before committing", "semantic_review", - "Summarize the intent and risk of the change instead of eyeballing line diffs."), - ("Before merging or marking a task done", "verify_tests", - "Run independent, execution-based verification. Never trust a self-reported 'done'."), - ("When you need correctness guarantees", "prove", - "Generate and check properties/proofs for pure functions."), - ("When code needs external services in tests", "mock_env", - "Spin up an ephemeral full-stack mock environment, then tear it down."), - ("To understand overall code health", "architectural_debt", - "Check the current architectural debt score before large changes."), + ( + "Before refactoring or deleting a symbol", + "impact", + "Get the blast radius (downstream dependents) before you change a shared symbol.", + ), + ( + "After producing a diff / before committing", + "semantic_review", + "Summarize the intent and risk of the change instead of eyeballing line diffs.", + ), + ( + "Before merging or marking a task done", + "verify_tests", + "Run independent, execution-based verification. Never trust a self-reported 'done'.", + ), + ( + "When you need correctness guarantees", + "prove", + "Generate and check properties/proofs for pure functions.", + ), + ( + "When code needs external services in tests", + "mock_env", + "Spin up an ephemeral full-stack mock environment, then tear it down.", + ), + ( + "To understand overall code health", + "architectural_debt", + "Check the current architectural debt score before large changes.", + ), +] + +# Memory playbook — only surfaced when SIN-Brain is installed (BR-2, Issue #15). +_MEMORY_PLAYBOOK = [ + ( + "Before starting a task", + "recall", + "Pull prior decisions, conventions and known pitfalls for this area first.", + ), + ( + "After making a non-obvious decision", + "remember", + "Persist the decision/convention/fix so future turns do not relitigate it.", + ), + ( + "When a subsystem returns a verdict", + "link_evidence", + "Attach the oracle/poc/ibd/sckg/adw verdict to the affected code entity.", + ), + ( + "When a memory is stale or wrong", + "forget", + "Remove outdated memories; `pin` the ones that must never be evicted.", + ), ] -def _build_block() -> str: - """Baut den Inhalt zwischen den Markern (ohne die Marker selbst).""" +# Red-zones: hard "do not" constraints. Negative constraints reliably steer +# agents away from anti-patterns far better than positive guidance alone. +_NEGATIVE_CONSTRAINTS = [ + "Do **not** mark a task done or merge without a passing `verify_tests` run.", + "Do **not** refactor or delete a shared symbol before checking `impact`.", + "Do **not** invent file paths, APIs or symbols — confirm via `recall` / graph context.", + "Do **not** discard a memory verdict to make a change look clean.", + "Do **not** weaken or delete tests to get them to pass.", + "Do **not** commit secrets, tokens or credentials.", +] + + +def _build_block(memory_available: bool = False, inject_text: str = "") -> str: + """Baut den Inhalt zwischen den Markern (ohne die Marker selbst). + + ``memory_available`` schaltet die Memory-Playbook-Zeilen frei; ``inject_text`` + ist der von SIN-Brain gelieferte Kontext-Block (SB-4), der unveraendert + eingebettet wird. + """ lines = [ "## SIN-Code Agent Tooling", "", @@ -43,8 +103,12 @@ def _build_block() -> str: "| Situation | Tool | Why |", "| --- | --- | --- |", ] - for situation, tool, why in _PLAYBOOK: + playbook = list(_PLAYBOOK) + if memory_available: + playbook += _MEMORY_PLAYBOOK + for situation, tool, why in playbook: lines.append(f"| {situation} | `{tool}` | {why} |") + lines += [ "", "### Rules", @@ -54,18 +118,72 @@ def _build_block() -> str: "3. **Prefer `semantic_review` over raw diffs** when assessing your own changes.", "4. If a tool is unavailable, continue gracefully and say so explicitly.", ] + if memory_available: + lines += [ + "5. **Start with `recall` and persist decisions with `remember`** so the", + " project's memory compounds across sessions.", + ] + + lines += [ + "", + "### Negative constraints (red-zones)", + "", + ] + lines += [f"- {c}" for c in _NEGATIVE_CONSTRAINTS] + + if inject_text.strip(): + lines += [ + "", + "### Project memory (SIN-Brain)", + "", + "", + inject_text.strip(), + ] + return "\n".join(lines) -def render_block() -> str: +def _memory_context() -> tuple[bool, str]: + """Return (sin-brain available?, inject text) from the memory adapter. + + Defensive: any failure degrades to (False, "") so `sin agents-md` always + produces a valid file even without SIN-Brain installed. + """ + try: + from sin_code_bundle import memory + + env = memory.detect_env() + if not env.available: + return False, "" + inject = "" + getter = getattr(memory, "inject", None) + if callable(getter): + try: + inject = getter() or "" + except Exception: # noqa: BLE001 - inject must never break generation + inject = "" + return True, inject + except Exception: # noqa: BLE001 + return False, "" + + +def render_block(memory_available: bool | None = None, inject_text: str | None = None) -> str: """Vollstaendiger, markierter SIN-Block (inkl. Marker).""" - return f"{START_MARKER}\n{_build_block()}\n{END_MARKER}" + if memory_available is None or inject_text is None: + detected_available, detected_inject = _memory_context() + memory_available = detected_available if memory_available is None else memory_available + inject_text = detected_inject if inject_text is None else inject_text + body = _build_block(memory_available=memory_available, inject_text=inject_text) + return f"{START_MARKER}\n{body}\n{END_MARKER}" -def render_full_document() -> str: +def render_full_document( + memory_available: bool | None = None, inject_text: str | None = None +) -> str: """Eine komplette AGENTS.md fuer den Fall, dass noch keine existiert.""" header = "# AGENTS.md\n\nGuidance for AI coding agents working in this repository.\n" - return f"{header}\n{render_block()}\n" + block = render_block(memory_available=memory_available, inject_text=inject_text) + return f"{header}\n{block}\n" def upsert(path: Path) -> str: @@ -77,10 +195,13 @@ def upsert(path: Path) -> str: Gibt eine kurze Statusmeldung zurueck. """ - block = render_block() + mem_available, inject_text = _memory_context() + block = render_block(memory_available=mem_available, inject_text=inject_text) if not path.exists(): path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(render_full_document()) + path.write_text( + render_full_document(memory_available=mem_available, inject_text=inject_text) + ) return f"Created {path} with SIN-Code block" content = path.read_text() diff --git a/src/sin_code_bundle/bench.py b/src/sin_code_bundle/bench.py index 095c20c..b93c727 100644 --- a/src/sin_code_bundle/bench.py +++ b/src/sin_code_bundle/bench.py @@ -18,6 +18,7 @@ hermes via a small AgentRunner. A DryRunRunner is included so `sin bench` works end-to-end without any LLM credits. """ + from __future__ import annotations import json @@ -282,9 +283,7 @@ def run_benchmark( summaries = {arm: _summarize(arm, results) for arm in arms} delta = 0.0 if "sin" in summaries and "control" in summaries: - delta = round( - summaries["sin"].resolved_rate - summaries["control"].resolved_rate, 4 - ) + delta = round(summaries["sin"].resolved_rate - summaries["control"].resolved_rate, 4) return BenchReport( arms=summaries, delta_resolved_rate=delta, @@ -368,8 +367,7 @@ def format_report(report: BenchReport) -> str: sign = "+" if report.delta_resolved_rate >= 0 else "" lines.append("-" * 40) lines.append( - f" SIN delta: {sign}{report.delta_resolved_rate * 100:.1f} pp " - "(percentage points)" + f" SIN delta: {sign}{report.delta_resolved_rate * 100:.1f} pp (percentage points)" ) lines.append("=" * 40) return "\n".join(lines) diff --git a/src/sin_code_bundle/budget.py b/src/sin_code_bundle/budget.py index 1791e95..875f858 100644 --- a/src/sin_code_bundle/budget.py +++ b/src/sin_code_bundle/budget.py @@ -4,6 +4,7 @@ long strings truncated, and an explicit `_truncated` flag is added so the agent knows more data exists. """ + from __future__ import annotations from typing import Any diff --git a/src/sin_code_bundle/cache.py b/src/sin_code_bundle/cache.py index d852194..bbb556f 100644 --- a/src/sin_code_bundle/cache.py +++ b/src/sin_code_bundle/cache.py @@ -4,6 +4,7 @@ the file set + their mtimes/sizes; invalidated automatically when files change. Stored under .sin/cache/ as JSON. """ + from __future__ import annotations import hashlib diff --git a/src/sin_code_bundle/cli.py b/src/sin_code_bundle/cli.py index b23915c..03de0c8 100644 --- a/src/sin_code_bundle/cli.py +++ b/src/sin_code_bundle/cli.py @@ -3,6 +3,7 @@ Subsysteme werden lazy und defensiv importiert: fehlt eines, bleibt der Rest nutzbar und es wird eine klare Meldung statt eines Importfehlers ausgegeben. """ + from __future__ import annotations import json @@ -12,9 +13,7 @@ app = typer.Typer(help="SIN-Code Bundle - Unified SOTA Agent-Engineering Stack") -gitnexus_app = typer.Typer( - help="GitNexus bridge - mandatory graph context for coder agents." -) +gitnexus_app = typer.Typer(help="GitNexus bridge - mandatory graph context for coder agents.") app.add_typer(gitnexus_app, name="gitnexus") markitdown_app = typer.Typer( @@ -22,9 +21,7 @@ ) app.add_typer(markitdown_app, name="markitdown") -rtk_app = typer.Typer( - help="RTK bridge - token-saving command proxy for coder agents." -) +rtk_app = typer.Typer(help="RTK bridge - token-saving command proxy for coder agents.") app.add_typer(rtk_app, name="rtk") codocs_app = typer.Typer(help="CoDocs - co-located docs standard (.doc.md companions).") app.add_typer(codocs_app, name="codocs") @@ -39,10 +36,7 @@ def _require(module: str, hint: str): try: return importlib.import_module(module) except ImportError: - typer.echo( - f"[SIN-BUNDLE] Subsystem '{module}' not installed. " - f"Install with: {hint}" - ) + typer.echo(f"[SIN-BUNDLE] Subsystem '{module}' not installed. Install with: {hint}") raise typer.Exit(code=1) @@ -70,12 +64,20 @@ def status(): from sin_code_bundle import gitnexus, markitdown, rtk report["GitNexus (graph context, external)"] = gitnexus.detect_env().available - report["MarkItDown (doc->markdown, external)"] = ( - markitdown.detect_env().mcp_available - ) + report["MarkItDown (doc->markdown, external)"] = markitdown.detect_env().mcp_available report["RTK (token-saving proxy, external)"] = rtk.detect_env().available # CoDocs ships inside the bundle itself, so it is always available. report["CoDocs (co-located docs)"] = True + + # SIN-Brain memory cortex (external package). Report presence plus tier + # sizes so it is obvious whether agents have a working memory. + from sin_code_bundle import memory + + mem_env = memory.detect_env() + report["SIN-Brain (memory cortex, external)"] = mem_env.available + if mem_env.available: + report["sin-brain:db"] = mem_env.db_path or "(default)" + report["sin-brain:tiers"] = mem_env.tiers typer.echo(json.dumps(report, indent=2)) @@ -122,11 +124,7 @@ def review(file_a: Path, file_b: Path): changes = ASTDiff().diff_files(str(file_a), str(file_b)) intents = IntentSummarizer().summarize(changes) risk = RiskScorer().score(changes) - typer.echo( - json.dumps( - {"intents": [i.__dict__ for i in intents], "risk": risk}, indent=2 - ) - ) + typer.echo(json.dumps({"intents": [i.__dict__ for i in intents], "risk": risk}, indent=2)) @app.command() @@ -349,9 +347,7 @@ def rtk_gain(): @app.command() def preflight( root: str = typer.Argument(".", help="Repository root"), - no_auto: bool = typer.Option( - False, "--no-auto", help="Do not auto-index; only report." - ), + no_auto: bool = typer.Option(False, "--no-auto", help="Do not auto-index; only report."), ): """Ensure agents are not coding blind: guarantee a fresh GitNexus index. @@ -380,6 +376,8 @@ def preflight( ) typer.echo("[PREFLIGHT] OK - GitNexus graph context is ready.") typer.echo(json.dumps(state.to_dict(), indent=2)) + + @codocs_app.command("check") def codocs_check( root: str = typer.Argument(".", help="Repository root to scan"), @@ -428,9 +426,7 @@ def codocs_install_skill( skill_src = Path(__file__).parent / "data" / "codocs" / "SKILL.md" if not skill_src.is_file(): # Fallback to the repo-level skills/ dir (editable installs). - skill_src = ( - Path(__file__).resolve().parents[2] / "skills" / "sin-codocs" / "SKILL.md" - ) + skill_src = Path(__file__).resolve().parents[2] / "skills" / "sin-codocs" / "SKILL.md" if not skill_src.is_file(): typer.echo("[CODOCS] Skill file not found in package.", err=True) raise typer.Exit(code=1) @@ -448,6 +444,8 @@ def codocs_install_skill( dest_dir.mkdir(parents=True, exist_ok=True) shutil.copy2(skill_src, dest_dir / "SKILL.md") typer.echo(f"[CODOCS] Installed skill -> {dest_dir / 'SKILL.md'}") + + @app.command(name="mcp-config") def mcp_config( client: str = typer.Argument(..., help="Target CLI: opencode | codex | hermes"), @@ -484,9 +482,7 @@ def mcp_config( @app.command(name="agents-md") def agents_md( - path: Path = typer.Option( - Path("AGENTS.md"), "--path", help="Target AGENTS.md path." - ), + path: Path = typer.Option(Path("AGENTS.md"), "--path", help="Target AGENTS.md path."), ): """Create or idempotently update an AGENTS.md describing SIN tool usage.""" from . import agents_md as gen @@ -501,7 +497,9 @@ def serve(): try: from mcp.server.fastmcp import FastMCP except ImportError: - typer.echo("[SIN-BUNDLE] mcp package required: pip install 'sin-code-bundle[mcp]'", err=True) + typer.echo( + "[SIN-BUNDLE] mcp package required: pip install 'sin-code-bundle[mcp]'", err=True + ) raise typer.Exit(code=1) mcp = FastMCP("sin-code-bundle") @@ -526,9 +524,7 @@ def semantic_diff(file_a: str, file_b: str) -> str: changes = ASTDiff().diff_files(file_a, file_b) intents = IntentSummarizer().summarize(changes) risk = RiskScorer().score(changes) - return json.dumps( - {"intents": [i.__dict__ for i in intents], "risk": risk} - ) + return json.dumps({"intents": [i.__dict__ for i in intents], "risk": risk}) except ImportError: pass @@ -544,7 +540,6 @@ def architectural_debt() -> str: except ImportError: pass - try: from sin_code_oracle import VerificationOracle @@ -588,7 +583,7 @@ def mock_env(action: str = "up", port: int = 8888) -> str: pass try: - from sin_code_orchestration import Orchestrator, TaskSpec, Role + from sin_code_orchestration import Orchestrator, Role, TaskSpec @mcp.tool() def orchestrate(task_id: str, role: str, input_data: str) -> str: @@ -621,11 +616,13 @@ def semantic_review(file_a: str, file_b: str) -> str: changes = ASTDiff().diff_files(file_a, file_b) intents = IntentSummarizer().summarize(changes) risk = RiskScorer().score(changes) - return json.dumps({ - "intents": [i.__dict__ for i in intents], - "risk": risk, - "recommendation": "Approve" if risk["risk"] == "low" else "Review Manually" - }) + return json.dumps( + { + "intents": [i.__dict__ for i in intents], + "risk": risk, + "recommendation": "Approve" if risk["risk"] == "low" else "Review Manually", + } + ) except ImportError: pass @@ -680,6 +677,13 @@ def codocs_check(root: str = ".") -> str: } ) + # SIN-Brain memory cortex (external package, BR-1 / Issue #14). Registers + # recall/remember/forget/pin/link_evidence only when sin-brain is importable; + # a missing package leaves the server fully functional (graceful degradation). + from sin_code_bundle import memory + + memory.register_tools(mcp) + typer.echo("[SIN-BUNDLE] MCP server starting (stdio).", err=True) mcp.run() @@ -687,6 +691,7 @@ def codocs_check(root: str = ".") -> str: if __name__ == "__main__": app() + # --------------------------------------------------------------------------- # # sin bench — SWE-bench A/B harness # --------------------------------------------------------------------------- # @@ -699,12 +704,8 @@ def bench( runner: str = typer.Option( "dry", help="Agent runner: 'dry' | 'opencode' | 'codex' | 'hermes'." ), - arms: str = typer.Option( - "control,sin", help="Comma-separated arms to run." - ), - out: str | None = typer.Option( - None, "--out", help="Write the full JSON report to this path." - ), + arms: str = typer.Option("control,sin", help="Comma-separated arms to run."), + out: str | None = typer.Option(None, "--out", help="Write the full JSON report to this path."), ): """Run the SIN-Code A/B benchmark and report the resolved-rate delta.""" from sin_code_bundle.bench import ( @@ -857,9 +858,7 @@ def doctor(root: str = typer.Option(".", help="Project root.")): typer.echo(" (no supported source files detected)") for r in rows: mark = "OK " if r["installed"] else "-- " - typer.echo( - f" {mark}{r['language']:<11} {r['files']:>5} files server={r['server']}" - ) + typer.echo(f" {mark}{r['language']:<11} {r['files']:>5} files server={r['server']}") if not r["installed"]: typer.echo(f" install: {r['install_hint']}") diff --git a/src/sin_code_bundle/codocs.py b/src/sin_code_bundle/codocs.py index 0532c4d..856d41f 100644 --- a/src/sin_code_bundle/codocs.py +++ b/src/sin_code_bundle/codocs.py @@ -15,6 +15,7 @@ It is intentionally dependency-free (stdlib only) so it works even when the optional SIN-Code subsystems are not installed. """ + from __future__ import annotations import re @@ -40,12 +41,35 @@ # File extensions we consider "code" and therefore eligible for a Docs: ref. # Makefile and Dockerfile are matched by name in ``_is_code_file``. CODE_SUFFIXES = { - ".py", ".pyi", - ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", - ".rs", ".go", ".java", ".kt", ".kts", ".scala", - ".c", ".h", ".cc", ".cpp", ".hpp", ".cs", - ".rb", ".php", ".swift", ".sh", ".bash", ".zsh", - ".yaml", ".yml", ".toml", + ".py", + ".pyi", + ".ts", + ".tsx", + ".js", + ".jsx", + ".mjs", + ".cjs", + ".rs", + ".go", + ".java", + ".kt", + ".kts", + ".scala", + ".c", + ".h", + ".cc", + ".cpp", + ".hpp", + ".cs", + ".rb", + ".php", + ".swift", + ".sh", + ".bash", + ".zsh", + ".yaml", + ".yml", + ".toml", } CODE_FILENAMES = {"Makefile", "Dockerfile", "Justfile"} @@ -72,8 +96,8 @@ class DocReference: """A ``Docs:`` reference discovered in a source file.""" source: Path - doc: str # raw referenced path, as written - resolved: Path # absolute path the reference resolves to + doc: str # raw referenced path, as written + resolved: Path # absolute path the reference resolves to exists: bool def to_dict(self) -> dict: diff --git a/src/sin_code_bundle/gitnexus.py b/src/sin_code_bundle/gitnexus.py index 830a71c..baf6d95 100644 --- a/src/sin_code_bundle/gitnexus.py +++ b/src/sin_code_bundle/gitnexus.py @@ -15,6 +15,7 @@ (``ai-context``, ``query``, ``context``, ``impact``), * MCP wiring so OpenCode / Codex / Hermes each get the GitNexus MCP server. """ + from __future__ import annotations import json @@ -234,6 +235,7 @@ def doctor(root: str = ".", env: GitNexusEnv | None = None) -> dict[str, Any]: # MCP wiring for coder agents # --------------------------------------------------------------------------- # + # The single MCP server entry every agent should run. GitNexus exposes its graph # tools over stdio via `gitnexus mcp`. def mcp_server_command(package: str = GITNEXUS_PACKAGE) -> dict[str, Any]: @@ -277,11 +279,7 @@ def _wire_opencode(package: str) -> str: def _wire_codex(package: str) -> str: path = _codex_config_path() path.parent.mkdir(parents=True, exist_ok=True) - block = ( - "\n[mcp_servers.gitnexus]\n" - 'command = "npx"\n' - f'args = ["-y", "{package}", "mcp"]\n' - ) + block = f'\n[mcp_servers.gitnexus]\ncommand = "npx"\nargs = ["-y", "{package}", "mcp"]\n' existing = path.read_text() if path.is_file() else "" if "[mcp_servers.gitnexus]" in existing: return str(path) # already wired; leave user edits intact diff --git a/src/sin_code_bundle/lsp_backend.py b/src/sin_code_bundle/lsp_backend.py index fa1bcd1..04de222 100644 --- a/src/sin_code_bundle/lsp_backend.py +++ b/src/sin_code_bundle/lsp_backend.py @@ -13,6 +13,7 @@ results and flags `source="treesitter"`, so the agent still gets a useful signal and the bundle keeps working (consistent with `sin status`). """ + from __future__ import annotations import asyncio diff --git a/src/sin_code_bundle/lsp_bootstrap.py b/src/sin_code_bundle/lsp_bootstrap.py index 25dbc57..68f564f 100644 --- a/src/sin_code_bundle/lsp_bootstrap.py +++ b/src/sin_code_bundle/lsp_bootstrap.py @@ -4,6 +4,7 @@ impact analysis. We never silently install global tooling; we report and offer the exact install command. """ + from __future__ import annotations import shutil diff --git a/src/sin_code_bundle/markitdown.py b/src/sin_code_bundle/markitdown.py index e02e29e..4e7cfa4 100644 --- a/src/sin_code_bundle/markitdown.py +++ b/src/sin_code_bundle/markitdown.py @@ -15,6 +15,7 @@ * MCP wiring so OpenCode / Codex / Hermes each get the MarkItDown MCP server, mirroring upstream's recommended ``uvx markitdown-mcp`` invocation. """ + from __future__ import annotations import json @@ -66,8 +67,7 @@ def mcp_command(self) -> dict[str, Any]: def cli_cmd(self) -> str: if not self.cli: raise MarkItDownError( - "`markitdown` CLI not found. Install with " - "`pip install 'markitdown[all]'`." + "`markitdown` CLI not found. Install with `pip install 'markitdown[all]'`." ) return self.cli @@ -103,9 +103,7 @@ def convert(path: str, env: MarkItDownEnv | None = None, timeout: int = 300) -> except subprocess.TimeoutExpired as exc: # pragma: no cover - timing dependent raise MarkItDownError(f"markitdown timed out after {timeout}s") from exc if proc.returncode != 0: - raise MarkItDownError( - f"markitdown failed ({proc.returncode}): {proc.stderr.strip()}" - ) + raise MarkItDownError(f"markitdown failed ({proc.returncode}): {proc.stderr.strip()}") return proc.stdout @@ -168,11 +166,7 @@ def _wire_codex(env: MarkItDownEnv | None) -> str: path = _codex_config_path() path.parent.mkdir(parents=True, exist_ok=True) args_repr = ", ".join(f'"{a}"' for a in args) - block = ( - "\n[mcp_servers.markitdown]\n" - f'command = "{command}"\n' - f"args = [{args_repr}]\n" - ) + block = f'\n[mcp_servers.markitdown]\ncommand = "{command}"\nargs = [{args_repr}]\n' existing = path.read_text() if path.is_file() else "" if "[mcp_servers.markitdown]" in existing: return str(path) # already wired; leave user edits intact @@ -216,8 +210,6 @@ def setup_agents( for agent in chosen: wirer = _WIRERS.get(agent) if not wirer: - raise MarkItDownError( - f"Unknown agent: {agent!r}. Known: {', '.join(AGENTS)}" - ) + raise MarkItDownError(f"Unknown agent: {agent!r}. Known: {', '.join(AGENTS)}") written[agent] = wirer(env) return written diff --git a/src/sin_code_bundle/mcp_config.py b/src/sin_code_bundle/mcp_config.py index f7cf3ce..66c04cc 100644 --- a/src/sin_code_bundle/mcp_config.py +++ b/src/sin_code_bundle/mcp_config.py @@ -9,6 +9,7 @@ Die Funktionen liefern reine Strings (fuer ``--stdout``) sowie Helfer zum idempotenten Mergen in eine bestehende Konfigurationsdatei (fuer ``--write``). """ + from __future__ import annotations import json @@ -125,9 +126,7 @@ def generate(client: str, env: dict[str, str] | None = None) -> str: return generate_codex(env) if client == "hermes": return generate_hermes(env) - raise ValueError( - f"Unknown client '{client}'. Supported: {', '.join(SUPPORTED_CLIENTS)}" - ) + raise ValueError(f"Unknown client '{client}'. Supported: {', '.join(SUPPORTED_CLIENTS)}") # --------------------------------------------------------------------------- # @@ -216,7 +215,11 @@ def _merge_codex_toml(path: Path, env: dict[str, str] | None) -> str: existing = path.read_text() cleaned = _strip_toml_table(existing, f"mcp_servers.{SERVER_NAME}") block = generate_codex(env) - sep = "" if cleaned == "" or cleaned.endswith("\n\n") else ("\n" if cleaned.endswith("\n") else "\n\n") + sep = ( + "" + if cleaned == "" or cleaned.endswith("\n\n") + else ("\n" if cleaned.endswith("\n") else "\n\n") + ) new_content = cleaned + sep + block path.parent.mkdir(parents=True, exist_ok=True) path.write_text(new_content) diff --git a/src/sin_code_bundle/memory.py b/src/sin_code_bundle/memory.py new file mode 100644 index 0000000..ea3a789 --- /dev/null +++ b/src/sin_code_bundle/memory.py @@ -0,0 +1,211 @@ +"""SIN-Brain memory adapter (BR-1, Issue #14). + +Thin, defensive bridge to the external ``sin_brain`` package. The bundle holds +**no** memory logic itself (that lives in SIN-Brain); this module only: + +- detects whether ``sin_brain`` is importable and reports tier sizes for + ``sin status`` (:func:`detect_env`), and +- exposes the five memory operations (:func:`recall`, :func:`remember`, + :func:`forget`, :func:`pin`, :func:`link_evidence`) as thin pass-throughs that + the MCP ``serve`` command registers as tools. + +Every entry point degrades gracefully: if ``sin_brain`` is absent, detection +reports ``available=False`` and the operations raise :class:`MemoryUnavailable`, +which the caller turns into a clean tool-level error instead of crashing the +server. +""" + +from __future__ import annotations + +import importlib +import importlib.util +import json +from dataclasses import dataclass, field +from typing import Any + +PACKAGE = "sin_brain" + +# Canonical enums (kept in lock-step with the plan + AGENTS.md guidance). +RECALL_SCOPES = ("recall", "archival", "graph") +REMEMBER_KINDS = ("decision", "convention", "fix", "pitfall", "preference") +REMEMBER_SCOPES = ("repo", "user") +EVIDENCE_SOURCES = ("oracle", "poc", "ibd", "sckg", "adw") + + +class MemoryUnavailable(RuntimeError): + """Raised when a memory operation is attempted without ``sin_brain``.""" + + +@dataclass +class MemoryEnv: + """Runtime availability snapshot for ``sin status``.""" + + available: bool + db_path: str | None = None + tiers: dict[str, int] = field(default_factory=dict) + detail: str = "" + + def to_dict(self) -> dict[str, Any]: + return { + "available": self.available, + "db_path": self.db_path, + "tiers": self.tiers, + "detail": self.detail, + } + + +def _tools_module(): + """Import ``sin_brain.mcp_tools`` or raise :class:`MemoryUnavailable`.""" + try: + return importlib.import_module(f"{PACKAGE}.mcp_tools") + except ImportError as exc: # pragma: no cover - exercised via detect_env + raise MemoryUnavailable( + "sin-brain not installed. Install with: pip install sin-brain" + ) from exc + + +def detect_env() -> MemoryEnv: + """Report whether SIN-Brain is installed and, if so, its tier sizes.""" + if importlib.util.find_spec(PACKAGE) is None: + return MemoryEnv(available=False, detail="sin_brain package not importable") + try: + mod = importlib.import_module(PACKAGE) + except ImportError as exc: # pragma: no cover + return MemoryEnv(available=False, detail=f"import error: {exc}") + + db_path = None + tiers: dict[str, int] = {} + # SIN-Brain exposes an optional, cheap introspection hook. Treat any failure + # as "available but stats unknown" rather than unavailable. + stats = getattr(mod, "stats", None) + if callable(stats): + try: + data = stats() + db_path = data.get("db_path") + tiers = data.get("tiers", {}) or {} + except Exception as exc: # noqa: BLE001 - never let stats break status + return MemoryEnv(available=True, detail=f"stats unavailable: {exc}") + return MemoryEnv(available=True, db_path=db_path, tiers=tiers, detail="ok") + + +# --------------------------------------------------------------------------- # +# Operations — thin pass-throughs to sin_brain.mcp_tools, JSON-string results. +# --------------------------------------------------------------------------- # +def recall(query: str, scope: str = "recall", k: int = 5) -> str: + """Tiered memory search. Returns JSON: ids + snippets (not full docs).""" + if scope not in RECALL_SCOPES: + raise ValueError(f"scope must be one of {RECALL_SCOPES}") + result = _tools_module().recall(query=query, scope=scope, k=k) + return result if isinstance(result, str) else json.dumps(result) + + +def remember(content: str, kind: str, ttl_days: int | None = None, scope: str = "repo") -> str: + """Self-editing memory write. Returns JSON with the new entry id.""" + if kind not in REMEMBER_KINDS: + raise ValueError(f"kind must be one of {REMEMBER_KINDS}") + if scope not in REMEMBER_SCOPES: + raise ValueError(f"scope must be one of {REMEMBER_SCOPES}") + result = _tools_module().remember(content=content, kind=kind, ttl_days=ttl_days, scope=scope) + return result if isinstance(result, str) else json.dumps(result) + + +def forget(id: str) -> str: + """Remove a memory entry. Returns JSON status.""" + result = _tools_module().forget(id=id) + return result if isinstance(result, str) else json.dumps(result) + + +def pin(id: str) -> str: + """Pin a memory entry so it is never evicted. Returns JSON status.""" + result = _tools_module().pin(id=id) + return result if isinstance(result, str) else json.dumps(result) + + +def link_evidence(entity: str, verdict: str, source: str) -> str: + """Attach a subsystem verdict to a code entity in the evidence graph.""" + if source not in EVIDENCE_SOURCES: + raise ValueError(f"source must be one of {EVIDENCE_SOURCES}") + result = _tools_module().link_evidence(entity=entity, verdict=verdict, source=source) + return result if isinstance(result, str) else json.dumps(result) + + +def inject() -> str: + """Return SIN-Brain's AGENTS.md inject block (SB-4), or '' if unavailable. + + Used by `sin agents-md` to embed the project's compiled memory context. The + bundle owns no formatting here — SIN-Brain returns ready-to-embed Markdown. + """ + if importlib.util.find_spec(PACKAGE) is None: + return "" + try: + mod = importlib.import_module(PACKAGE) + except ImportError: + return "" + fn = getattr(mod, "inject", None) + if not callable(fn): + return "" + try: + out = fn() + except Exception: # noqa: BLE001 - inject must never break callers + return "" + return out if isinstance(out, str) else "" + + +# --------------------------------------------------------------------------- # +# MCP registration — called by `sin serve`. Kept here (not in cli.py) so the +# wiring is unit-testable with a fake MCP object and no `mcp` dependency. +# --------------------------------------------------------------------------- # +TOOL_NAMES = ("recall", "remember", "forget", "pin", "link_evidence") + + +def register_tools(mcp: Any) -> list[str]: + """Register the five memory tools on ``mcp`` if SIN-Brain is available. + + Returns the names registered (empty when sin-brain is absent) so callers and + tests can assert on the wiring. Never raises on a missing package — graceful + degradation is the contract. + """ + if not detect_env().available: + return [] + + @mcp.tool() + def recall_tool(query: str, scope: str = "recall", k: int = 5) -> str: + """Search memory tiers (recall|archival|graph). Returns ids+snippets.""" + try: + return recall(query, scope=scope, k=k) + except (MemoryUnavailable, ValueError) as exc: + return json.dumps({"error": str(exc)}) + + @mcp.tool() + def remember_tool(content: str, kind: str, ttl_days: int = 0, scope: str = "repo") -> str: + """Persist a memory. kind: decision|convention|fix|pitfall|preference.""" + try: + return remember(content, kind, ttl_days=ttl_days or None, scope=scope) + except (MemoryUnavailable, ValueError) as exc: + return json.dumps({"error": str(exc)}) + + @mcp.tool() + def forget_tool(id: str) -> str: + """Delete a memory entry by id.""" + try: + return forget(id) + except MemoryUnavailable as exc: + return json.dumps({"error": str(exc)}) + + @mcp.tool() + def pin_tool(id: str) -> str: + """Pin a memory entry so it is never evicted.""" + try: + return pin(id) + except MemoryUnavailable as exc: + return json.dumps({"error": str(exc)}) + + @mcp.tool() + def link_evidence_tool(entity: str, verdict: str, source: str) -> str: + """Attach a subsystem verdict (oracle|poc|ibd|sckg|adw) to a code entity.""" + try: + return link_evidence(entity, verdict, source) + except (MemoryUnavailable, ValueError) as exc: + return json.dumps({"error": str(exc)}) + + return list(TOOL_NAMES) diff --git a/src/sin_code_bundle/policy.py b/src/sin_code_bundle/policy.py index ed61bbe..e0b96f4 100644 --- a/src/sin_code_bundle/policy.py +++ b/src/sin_code_bundle/policy.py @@ -8,6 +8,7 @@ Policy is loaded from .sin/policy.yaml (falls back to safe defaults). """ + from __future__ import annotations import hashlib @@ -51,9 +52,7 @@ class PolicyError(RuntimeError): @dataclass class Policy: rules: dict[RiskClass, Decision] = field(default_factory=lambda: dict(DEFAULT_POLICY)) - auto_approve: bool = field( - default_factory=lambda: os.environ.get("SIN_AUTO_APPROVE") == "1" - ) + auto_approve: bool = field(default_factory=lambda: os.environ.get("SIN_AUTO_APPROVE") == "1") @classmethod def load(cls, root: Path = Path(".")) -> "Policy": @@ -140,9 +139,7 @@ def ensure_within_root(target: str | Path, root: Optional[str | Path] = None) -> else Path(target).resolve() # type: ignore[arg-type] ) if root_path not in resolved.parents and resolved != root_path: - raise PolicyError( - f"path '{resolved}' is outside project root '{root_path}'" - ) + raise PolicyError(f"path '{resolved}' is outside project root '{root_path}'") return resolved @@ -167,9 +164,7 @@ def guarded( if decision == "deny": audit.record(tool, args, decision, "denied") - raise PolicyError( - f"tool '{tool}' denied by policy (risk={TOOL_RISK.get(tool)})" - ) + raise PolicyError(f"tool '{tool}' denied by policy (risk={TOOL_RISK.get(tool)})") if decision == "ask": approved = policy.auto_approve or (approver(tool, args) if approver else False) diff --git a/src/sin_code_bundle/rtk.py b/src/sin_code_bundle/rtk.py index 3b199a1..3f69925 100644 --- a/src/sin_code_bundle/rtk.py +++ b/src/sin_code_bundle/rtk.py @@ -11,6 +11,7 @@ simply discovers the upstream ``rtk`` binary and drives ``rtk init`` for each agent so the whole SIN-Code coder fleet benefits from the same token savings. """ + from __future__ import annotations import shutil @@ -70,17 +71,13 @@ def init_args(agent: str) -> list[str]: def _run(cmd: list[str], timeout: int = 120) -> str: try: - proc = subprocess.run( - cmd, capture_output=True, text=True, timeout=timeout - ) + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) except FileNotFoundError as exc: # pragma: no cover - guarded by detect_env raise RtkError(f"Failed to execute {cmd[0]!r}: {exc}") from exc except subprocess.TimeoutExpired as exc: # pragma: no cover - timing dependent raise RtkError(f"rtk timed out after {timeout}s") from exc if proc.returncode != 0: - raise RtkError( - f"`{' '.join(cmd)}` failed ({proc.returncode}): {proc.stderr.strip()}" - ) + raise RtkError(f"`{' '.join(cmd)}` failed ({proc.returncode}): {proc.stderr.strip()}") return proc.stdout.strip() diff --git a/src/sin_code_bundle/safety.py b/src/sin_code_bundle/safety.py index b9c7fd1..88bc408 100644 --- a/src/sin_code_bundle/safety.py +++ b/src/sin_code_bundle/safety.py @@ -1,4 +1,5 @@ """Hardened subprocess + input-sanitization helpers shared by all subsystems.""" + from __future__ import annotations import subprocess @@ -42,9 +43,7 @@ def sanitize_prompt(text: str, max_len: int = 8000) -> str: safe_lines = [] for line in text.splitlines(): low = line.strip().lower() - if low.startswith( - ("system:", "developer:", "ignore previous", "you are now") - ): + if low.startswith(("system:", "developer:", "ignore previous", "you are now")): safe_lines.append("[redacted suspicious instruction]") else: safe_lines.append(line) diff --git a/src/sin_code_bundle/skills.py b/src/sin_code_bundle/skills.py index 4b33fb7..d6e5e35 100644 --- a/src/sin_code_bundle/skills.py +++ b/src/sin_code_bundle/skills.py @@ -10,6 +10,7 @@ This mirrors how cross-agent tools (Ulis/Nexel) keep a single prompt library in sync across CLIs. """ + from __future__ import annotations import re diff --git a/tests/test_agent_integration.py b/tests/test_agent_integration.py index a5bf06c..31e75d3 100644 --- a/tests/test_agent_integration.py +++ b/tests/test_agent_integration.py @@ -1,10 +1,11 @@ """Tests fuer WS2 (mcp-config) und WS4 (agents-md).""" + import json from typer.testing import CliRunner +from sin_code_bundle import agents_md, mcp_config from sin_code_bundle.cli import app -from sin_code_bundle import mcp_config, agents_md runner = CliRunner() @@ -134,3 +135,51 @@ def test_cli_agents_md(tmp_path): result = runner.invoke(app, ["agents-md", "--path", str(target)]) assert result.exit_code == 0 assert target.exists() + + +# ----------------- WS4/BR-2: sin-brain inject + red-zones ------------------ # +def test_agents_md_has_negative_constraints(tmp_path): + """Red-zones section must always be present, even without sin-brain.""" + path = tmp_path / "AGENTS.md" + agents_md.upsert(path) + content = path.read_text() + assert "Negative constraints (red-zones)" in content + assert "Do **not** mark a task done" in content + + +def test_agents_md_block_omits_memory_when_absent(): + """Without sin-brain, no memory playbook rows or inject section.""" + block = agents_md.render_block(memory_available=False, inject_text="") + # No memory playbook rows (the row's "Why" text is unique to memory mode). + assert "project's memory compounds" not in block + assert "Pull prior decisions, conventions" not in block + assert "Project memory (SIN-Brain)" not in block + # but the tool guidance + red-zones are still there + assert "verify_tests" in block + assert "red-zones" in block + + +def test_agents_md_block_includes_memory_when_present(): + """With sin-brain, memory rows + injected context are embedded.""" + block = agents_md.render_block( + memory_available=True, + inject_text="### Recent decisions\n- Use RS256 for JWT.", + ) + assert "`recall`" in block + assert "`remember`" in block + assert "Project memory (SIN-Brain)" in block + assert "Use RS256 for JWT." in block + + +def test_agents_md_idempotent_with_memory(tmp_path): + """Re-running with the same inject text keeps a single managed block.""" + path = tmp_path / "AGENTS.md" + block = agents_md.render_block(memory_available=True, inject_text="X") + path.write_text(f"# AGENTS.md\n\n{block}\n") + first = path.read_text() + # Simulate a second generation with identical context by replacing in place. + start = first.index(agents_md.START_MARKER) + end = first.index(agents_md.END_MARKER) + len(agents_md.END_MARKER) + second = first[:start] + block + first[end:] + assert second == first + assert second.count(agents_md.START_MARKER) == 1 diff --git a/tests/test_bench.py b/tests/test_bench.py index 4a5df44..8a8d74c 100644 --- a/tests/test_bench.py +++ b/tests/test_bench.py @@ -1,8 +1,8 @@ """Tests for the SWE-bench harness — using DryRunRunner so no LLM or network needed.""" + import json from pathlib import Path - from sin_code_bundle.bench import ( ArmSummary, BenchReport, @@ -14,7 +14,6 @@ load_tasks_jsonl, ) - SAMPLE_TASK = Task( instance_id="test/repo__001", repo="test/repo", diff --git a/tests/test_consistency.py b/tests/test_consistency.py new file mode 100644 index 0000000..77ebea0 --- /dev/null +++ b/tests/test_consistency.py @@ -0,0 +1,51 @@ +"""Tests for the WS4 cross-repo consistency check.""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +SCRIPT = REPO_ROOT / "scripts" / "check_consistency.py" + + +def test_consistency_script_exists_and_is_executable(): + assert SCRIPT.is_file() + + +def test_consistency_passes_on_bundle_only_checkout(): + """On a clean bundle-only checkout the script must exit 0 (warnings only).""" + result = subprocess.run( + [sys.executable, str(SCRIPT)], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stdout + result.stderr + assert "all consistency checks passed" in result.stdout + + +def test_consistency_strict_fails_without_subsystems(): + """--strict treats missing subsystems as failures (exit 1).""" + result = subprocess.run( + [sys.executable, str(SCRIPT), "--strict"], + capture_output=True, + text=True, + ) + assert result.returncode == 1 + assert "not installed (strict)" in result.stdout + + +def test_version_alignment(): + """pyproject version must equal __init__.__version__.""" + import tomllib + + pyproject = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text()) + declared = pyproject["project"]["version"] + init_text = (REPO_ROOT / "src" / "sin_code_bundle" / "__init__.py").read_text() + runtime = next( + line.split("=", 1)[1].strip().strip('"').strip("'") + for line in init_text.splitlines() + if line.startswith("__version__") + ) + assert declared == runtime diff --git a/tests/test_gitnexus.py b/tests/test_gitnexus.py index 62b035e..708f38d 100644 --- a/tests/test_gitnexus.py +++ b/tests/test_gitnexus.py @@ -3,6 +3,7 @@ These never invoke real GitNexus/Node: subprocess and discovery are stubbed so the suite runs in CI without a Node toolchain. """ + from __future__ import annotations import json @@ -94,9 +95,7 @@ def fake_analyze(root, env=None, timeout=1800): def test_ensure_index_no_auto_does_not_build(tmp_path, monkeypatch): monkeypatch.setattr(gitnexus.shutil, "which", lambda name: f"/usr/bin/{name}") - monkeypatch.setattr( - gitnexus, "analyze", lambda *a, **k: pytest.fail("should not analyze") - ) + monkeypatch.setattr(gitnexus, "analyze", lambda *a, **k: pytest.fail("should not analyze")) state = gitnexus.ensure_index(str(tmp_path), auto=False) assert state.exists is False @@ -106,9 +105,7 @@ def test_ensure_index_skips_when_fresh(tmp_path, monkeypatch): idx = tmp_path / gitnexus.INDEX_DIRNAME idx.mkdir() (idx / "graph.db").write_text("fresh") - monkeypatch.setattr( - gitnexus, "analyze", lambda *a, **k: pytest.fail("should not rebuild") - ) + monkeypatch.setattr(gitnexus, "analyze", lambda *a, **k: pytest.fail("should not rebuild")) state = gitnexus.ensure_index(str(tmp_path), auto=True) assert state.exists is True @@ -143,9 +140,7 @@ def fake_run(cmd, cwd=None, timeout=300, capture=True): def test_query_raises_on_failure(monkeypatch): monkeypatch.setattr(gitnexus.shutil, "which", lambda name: f"/usr/bin/{name}") - monkeypatch.setattr( - gitnexus, "_run", lambda *a, **k: _Proc(rc=2, out="", err="boom") - ) + monkeypatch.setattr(gitnexus, "_run", lambda *a, **k: _Proc(rc=2, out="", err="boom")) with pytest.raises(gitnexus.GitNexusError): gitnexus.context("X") diff --git a/tests/test_lsp_backend.py b/tests/test_lsp_backend.py index 89ae12a..c709c43 100644 --- a/tests/test_lsp_backend.py +++ b/tests/test_lsp_backend.py @@ -1,8 +1,8 @@ """Tests for lsp_backend — primarily the tree-sitter fallback path, since LSP servers won't be available in CI. """ -from pathlib import Path +from pathlib import Path from sin_code_bundle.lsp_backend import ( ImpactResult, diff --git a/tests/test_markitdown.py b/tests/test_markitdown.py index 63f6e80..b0ff61b 100644 --- a/tests/test_markitdown.py +++ b/tests/test_markitdown.py @@ -3,6 +3,7 @@ No real MarkItDown / uvx invocation: discovery (shutil.which) and config HOME are stubbed so the suite runs in CI without the tool installed. """ + from __future__ import annotations import json @@ -27,9 +28,7 @@ def test_env_unavailable_without_runners(monkeypatch): def test_mcp_command_prefers_uvx(monkeypatch): - monkeypatch.setattr( - markitdown.shutil, "which", lambda name: f"/usr/bin/{name}" - ) + monkeypatch.setattr(markitdown.shutil, "which", lambda name: f"/usr/bin/{name}") cmd = markitdown.mcp_server_command() assert cmd == {"command": "uvx", "args": [markitdown.MARKITDOWN_MCP_PACKAGE]} @@ -50,9 +49,7 @@ def which(name): def fake_home(tmp_path, monkeypatch): monkeypatch.setattr(markitdown.Path, "home", classmethod(lambda cls: tmp_path)) # Make the runner resolvable so wiring uses the uvx command. - monkeypatch.setattr( - markitdown.shutil, "which", lambda name: f"/usr/bin/{name}" - ) + monkeypatch.setattr(markitdown.shutil, "which", lambda name: f"/usr/bin/{name}") return tmp_path @@ -100,17 +97,13 @@ def test_unknown_agent_raises(fake_home): # CLI convert wrapper # --------------------------------------------------------------------------- # def test_convert_missing_file_raises(monkeypatch, tmp_path): - monkeypatch.setattr( - markitdown.shutil, "which", lambda name: f"/usr/bin/{name}" - ) + monkeypatch.setattr(markitdown.shutil, "which", lambda name: f"/usr/bin/{name}") with pytest.raises(markitdown.MarkItDownError): markitdown.convert(str(tmp_path / "nope.pdf")) def test_convert_runs_cli(monkeypatch, tmp_path): - monkeypatch.setattr( - markitdown.shutil, "which", lambda name: f"/usr/bin/{name}" - ) + monkeypatch.setattr(markitdown.shutil, "which", lambda name: f"/usr/bin/{name}") src = tmp_path / "doc.txt" src.write_text("hello") diff --git a/tests/test_memory.py b/tests/test_memory.py new file mode 100644 index 0000000..0eded25 --- /dev/null +++ b/tests/test_memory.py @@ -0,0 +1,111 @@ +"""Tests for the SIN-Brain memory adapter (BR-1, Issue #14). + +These run without `sin_brain` installed: we simulate presence/absence by +injecting fake modules into sys.modules, so the bundle's graceful-degradation +contract is verified in isolation. +""" + +from __future__ import annotations + +import json +import sys +import types + +import pytest + +from sin_code_bundle import memory + + +@pytest.fixture +def fake_sin_brain(monkeypatch): + """Inject a fake `sin_brain` + `sin_brain.mcp_tools` into sys.modules.""" + pkg = types.ModuleType("sin_brain") + + def stats(): + return {"db_path": "/tmp/sin-brain.db", "tiers": {"core": 3, "recall": 42}} + + pkg.stats = stats + + tools = types.ModuleType("sin_brain.mcp_tools") + tools.recall = lambda query, scope, k: json.dumps({"hits": [query, scope, k]}) + tools.remember = lambda content, kind, ttl_days, scope: json.dumps({"id": "m1", "kind": kind}) + tools.forget = lambda id: json.dumps({"forgot": id}) + tools.pin = lambda id: json.dumps({"pinned": id}) + tools.link_evidence = lambda entity, verdict, source: json.dumps( + {"entity": entity, "verdict": verdict, "source": source} + ) + pkg.mcp_tools = tools + + monkeypatch.setitem(sys.modules, "sin_brain", pkg) + monkeypatch.setitem(sys.modules, "sin_brain.mcp_tools", tools) + # importlib.util.find_spec relies on a real spec; give the module one. + pkg.__spec__ = types.SimpleNamespace(name="sin_brain") + return pkg + + +class FakeMCP: + """Minimal stand-in for FastMCP capturing registered tool names.""" + + def __init__(self): + self.registered: list[str] = [] + + def tool(self): + def deco(fn): + self.registered.append(fn.__name__) + return fn + + return deco + + +# --------------------------- graceful degradation --------------------------- # +def test_detect_env_absent(): + env = memory.detect_env() + assert env.available is False + assert env.tiers == {} + + +def test_operations_raise_when_absent(): + with pytest.raises(memory.MemoryUnavailable): + memory.recall("anything") + with pytest.raises(memory.MemoryUnavailable): + memory.forget("x") + + +def test_register_tools_noop_when_absent(): + mcp = FakeMCP() + assert memory.register_tools(mcp) == [] + assert mcp.registered == [] + + +# ----------------------------- with sin-brain ------------------------------ # +def test_detect_env_present(fake_sin_brain): + env = memory.detect_env() + assert env.available is True + assert env.db_path == "/tmp/sin-brain.db" + assert env.tiers["recall"] == 42 + + +def test_recall_passthrough(fake_sin_brain): + out = json.loads(memory.recall("login bug", scope="archival", k=3)) + assert out["hits"] == ["login bug", "archival", 3] + + +def test_remember_validates_kind(fake_sin_brain): + with pytest.raises(ValueError): + memory.remember("x", kind="bogus") + out = json.loads(memory.remember("use RS256", kind="decision")) + assert out["kind"] == "decision" + + +def test_link_evidence_validates_source(fake_sin_brain): + with pytest.raises(ValueError): + memory.link_evidence("mod.fn", "pass", source="bogus") + out = json.loads(memory.link_evidence("mod.fn", "pass", source="oracle")) + assert out["source"] == "oracle" + + +def test_register_tools_wires_all_five(fake_sin_brain): + mcp = FakeMCP() + names = memory.register_tools(mcp) + assert set(names) == set(memory.TOOL_NAMES) + assert len(mcp.registered) == 5 diff --git a/tests/test_rtk.py b/tests/test_rtk.py index 284058a..e39c260 100644 --- a/tests/test_rtk.py +++ b/tests/test_rtk.py @@ -3,6 +3,7 @@ No real RTK invocation: discovery (shutil.which) and subprocess are stubbed so the suite runs in CI without the rtk binary. """ + from __future__ import annotations import pytest