From a5b06245076d03d4f0d14d4cfc8a5ce380da2b37 Mon Sep 17 00:00:00 2001 From: Cristian Girlea Date: Thu, 15 Oct 2026 09:45:14 +0300 Subject: [PATCH] chore: add the agent setup (AGENTS.md, CLAUDE.md, .claude) --- .claude/hooks/repo-rules.sh | 53 +++++++ .claude/rules/mcp-server.md | 23 +++ .claude/rules/php-client.md | 24 ++++ .claude/rules/worker.md | 25 ++++ .claude/settings.json | 201 +++++++++++++++++++++++++++ .claude/skills/check/SKILL.md | 39 ++++++ .gitattributes | 2 + .gitignore | 4 + AGENTS.md | 93 +++++++++++++ CLAUDE.md | 10 ++ tests/python/test_repo_rules_hook.py | 108 ++++++++++++++ 11 files changed, 582 insertions(+) create mode 100644 .claude/hooks/repo-rules.sh create mode 100644 .claude/rules/mcp-server.md create mode 100644 .claude/rules/php-client.md create mode 100644 .claude/rules/worker.md create mode 100644 .claude/settings.json create mode 100644 .claude/skills/check/SKILL.md create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 tests/python/test_repo_rules_hook.py diff --git a/.claude/hooks/repo-rules.sh b/.claude/hooks/repo-rules.sh new file mode 100644 index 0000000..4d528ee --- /dev/null +++ b/.claude/hooks/repo-rules.sh @@ -0,0 +1,53 @@ +#!/bin/sh +# After an edit: what CONTRIBUTING.md requires always to hold in the file just edited. +# PHP under src/, examples/ or tests/php declares strict types and uses spaces; a worker module +# (worker/ai_bridge) imports no model dependency at module level, since that path is standard library only. +# Exit 2 reports the problem to the agent; the edit itself has already happened. +cd "${CLAUDE_PROJECT_DIR:-.}" || exit 0 +input=$(cat) +file=$(printf '%s' "$input" | sed -n 's/.*"file_path"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1) +[ -n "$file" ] || exit 0 +# The path arrives absolute, on Windows with backslashes (JSON-escaped as \\) and a drive letter of either +# case; make it repository-relative. +file=$(printf '%s' "$file" | sed 's#\\\\#/#g; s#\\#/#g') +root=$(pwd -W 2>/dev/null || pwd) +upper() { printf '%s' "$1" | awk '{ print toupper(substr($0, 1, 1)) substr($0, 2) }'; } +case $file in + [A-Za-z]:/*) file=$(upper "$file"); root=$(upper "$root") ;; +esac +case $file in + "$root"/*) file=${file#"$root"/} ;; + "$PWD"/*) file=${file#"$PWD"/} ;; +esac +[ -f "$file" ] || exit 0 +msg="" +case $file in + src/*.php|examples/*.php|tests/php/*.php) + if ! head -n 10 "$file" | grep -q 'declare(strict_types=1);'; then + msg="$file: add declare(strict_types=1); right after the opening tag (CONTRIBUTING.md)." + fi + if grep -q "$(printf '\t')" "$file"; then + msg="${msg:+$msg +}$file: indent with four spaces, not tabs (CONTRIBUTING.md)." + fi + ;; + worker/ai_bridge/*.py) + # Any import statement naming a model dependency outside a function body: at column zero, or + # indented under try/if at module level. Imports inside functions are indented too, so an indented + # match is only reported when no def precedes it. + deps='(numpy|onnxruntime|tokenizers|torch|transformers)' + if awk -v re="^[[:space:]]*(import[[:space:]]+([A-Za-z_.]+[[:space:]]*,[[:space:]]*)*${deps}([[:space:].,]|$)|from[[:space:]]+${deps}([[:space:].]|$))" ' + /^[[:space:]]*(def|async[[:space:]]+def|class)[[:space:]]/ { infn = 1 } + /^[^[:space:]#]/ && !/^(def|async[[:space:]]+def|class)[[:space:]]/ { infn = 0 } + $0 ~ re && !infn { found = 1 } + END { exit found ? 0 : 1 }' "$file"; then + msg="$file: a model dependency is imported at module level; import it inside the ONNX function only (the worker path is standard library only)." + fi + ;; + *) exit 0 ;; +esac +if [ -n "$msg" ]; then + printf '%s\n' "$msg" >&2 + exit 2 +fi +exit 0 diff --git a/.claude/rules/mcp-server.md b/.claude/rules/mcp-server.md new file mode 100644 index 0000000..581851b --- /dev/null +++ b/.claude/rules/mcp-server.md @@ -0,0 +1,23 @@ +--- +paths: + - "mcp_server/**" + - "tests/mcp/**" + - "scripts/mcp_*" + - "requirements-mcp.*" +--- +# The MCP server + +- The tool list is a literal in `tools.py`, never read from the worker: test-only tasks are never tools, + whatever `BRIDGE_TEST_TASKS` says, and the suite runs with it set to prove that. +- `BACKENDS` names what the worker reports, per backend and task, and must equal + `ai_bridge.tasks.MODEL_NAMES` (`tests/mcp/test_models.py`); this package cannot import the worker. + Every timing claim in a tool description is a measured median from `docs/testing.md` (a test checks), + every path parameter says where it resolves, and every position in a result says what it counts. +- Paths resolve under `BRIDGE_MCP_ROOT` and must stay inside it after following symlinks; the server + writes nothing, and the index builder is the only writer. A worker's free text and the token are never + reflected; only its fixed error code is. +- A call times out after `BRIDGE_MCP_TIMEOUT_MS` and then cancels the job; a full worker is waited out + within the call's own budget. The MCP server never shares a worker with an application. +- Dependencies are `requirements-mcp.txt` with a hash lock; `mcp-fetcher` downloads the wheels once and + `mcp-tests` installs them offline into a tmpfs. Pin changes are reviewed, regenerated deliberately and + tested before they land. diff --git a/.claude/rules/php-client.md b/.claude/rules/php-client.md new file mode 100644 index 0000000..6096b44 --- /dev/null +++ b/.claude/rules/php-client.md @@ -0,0 +1,24 @@ +--- +paths: + - "src/**" + - "examples/**" + - "tests/php/**" +--- +# The PHP client + +- Every file starts with `declare(strict_types=1)`; four-space indentation; PHP `>=8.2 <8.6` with + `ext-curl` and `ext-json` only. No `ext-mbstring`: `Client::codePoints()` counts code points as bytes + minus UTF-8 continuation bytes, and new text limits go through it. +- Warnings are failures. `tests/php/run.php` promotes every warning and deprecation to an exception, so a + call deprecated on PHP 8.5 (as `curl_close()` was) fails the suite: release handles by dropping them. + CI runs the suite on 8.2, 8.3, 8.4 and 8.5; to reproduce one locally, set `BRIDGE_PHP_IMAGE` to that + job's pinned image from `.github/workflows/tests.yml` and rerun the same compose commands. +- Tests are `check()` and `rejects()` calls: `tests/php/run.php` runs offline (contract checks and one + transport error), and `tests/php/integration.php` loads it and then drives the client against the real + `worker`, `fault-worker` and `hostile` peers over HTTP. The path through FrankenPHP is + `tests/integration.py`. A new behaviour gets a success case, invalid-input cases and, where the worker + is involved, an integration case. Syntax is checked with `php -l` over `src`, `examples` and `tests/php`. +- The client never retries a `POST` on its own (a transport failure leaves the submission uncertain), and + `wait()` never cancels; the MCP server cancels on its own deadline on purpose. Keep both as + `docs/protocol.md` describes, and change the doc with the contract. +- The example routes have no end-user authentication and publish no ports; don't add either to the examples. diff --git a/.claude/rules/worker.md b/.claude/rules/worker.md new file mode 100644 index 0000000..f83d613 --- /dev/null +++ b/.claude/rules/worker.md @@ -0,0 +1,25 @@ +--- +paths: + - "worker/**" + - "tests/python/**" + - "scripts/fetch_model.py" +--- +# The Python worker + +- The worker path is standard library only. `numpy`, `onnxruntime` and `tokenizers` are imported inside + the ONNX functions of `tasks.py`, never at module top, so the demo backend and every test run without + them; the post-edit hook reports a top-level import of a model dependency. Model dependencies belong + in `requirements-model.txt` and its hash lock, downloaded only by the `fetcher` service; the model + worker installs them offline from `.cache/wheels` into a tmpfs when it starts. +- Tasks are an allowlist in `tasks.py`, each with `validate()` before `execute()` and each run in its own + process. Clients get fixed error codes, never exception text, and the token is never logged or returned. + Test-only tasks exist only with `BRIDGE_TEST_TASKS=1` (compose's `fault-worker`). +- `MODEL_NAMES` is what every result reports as `model` and what the MCP server's `BACKENDS` table must + equal (`tests/mcp/test_models.py`); change both together. +- Limits live in three places: concurrency, capacity and retention are `Settings` in `jobs.py` (defaults + from the environment in `server.main`), the body size is `MAX_BODY` in `server.py`, and the field, + count and character limits are the `MAX_*` constants in `tasks.py`; the deadline range is checked in + `JobStore.submit`. `docs/protocol.md` documents them; change the doc with the limit. +- Tests run offline with `python -m unittest discover -s tests/python` inside `python-tests` + (`network_mode: none`). A change to the model path belongs on `model-smoke.yml`'s `paths:` list, so + the real-model smoke tests run for it; keep that list current when a file moves. diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..db8524c --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,201 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-settings.json", + "attribution": { + "commit": "", + "pr": "", + "sessionUrl": false + }, + "permissions": { + "allow": [ + "Bash(docker compose -f docker/compose.yaml config)", + "Bash(docker compose -f docker/compose.yaml --profile * config)", + "Bash(docker compose -f docker/compose.yaml run --rm --no-deps python-tests)", + "Bash(docker compose -f docker/compose.yaml run --rm --no-deps php-tests)", + "Bash(docker compose -f docker/compose.yaml run --rm --no-deps php-tests sh -ec *)", + "Bash(docker compose -f docker/compose.yaml run --rm --no-deps php-integration)", + "Bash(docker compose -f docker/compose.yaml run --rm --no-deps integration)", + "Bash(docker compose -f docker/compose.yaml run --rm --no-deps php-example)", + "Bash(docker compose -f docker/compose.yaml run --rm --no-deps mcp-fetcher)", + "Bash(docker compose -f docker/compose.yaml run --rm --no-deps mcp-tests)", + "Bash(docker compose -f docker/compose.yaml up -d worker frankenphp fault-worker hostile)", + "Bash(docker compose -f docker/compose.yaml up -d worker frankenphp)", + "Bash(docker compose -f docker/compose.yaml down)", + "Bash(docker compose -f docker/compose.yaml down *)", + "Bash(docker compose -f docker/compose.yaml --profile * down)", + "Bash(docker compose -f docker/compose.yaml --profile * down *)", + "Bash(docker compose -f docker/compose.yaml logs *)", + "Bash(docker compose -f docker/compose.yaml --profile * logs *)", + "Bash(docker compose -f docker/compose.yaml ps *)", + "Bash(docker compose -f docker/compose.yaml --profile * ps *)", + "Bash(docker compose version)", + "Bash(docker version)", + "Bash(docker ps *)", + "Bash(git status *)", + "Bash(git diff *)", + "Bash(git log *)", + "Bash(git show *)", + "Bash(git grep *)", + "Bash(git blame *)", + "Bash(git worktree list *)", + "Bash(git branch)", + "Bash(git branch --show-current)", + "Bash(git branch --list *)", + "Bash(git branch -a)", + "Bash(git branch -r)", + "Bash(git branch -v)", + "Bash(git branch -vv)", + "Bash(gh pr view *)", + "Bash(gh pr list *)", + "Bash(gh pr checks *)", + "Bash(gh pr diff *)", + "Bash(gh issue view *)", + "Bash(gh issue list *)", + "Bash(gh run view *)", + "Bash(gh run list *)", + "Bash(gh workflow view *)", + "Bash(gh workflow list *)", + "PowerShell(docker compose -f docker/compose.yaml config)", + "PowerShell(docker compose -f docker/compose.yaml --profile * config)", + "PowerShell(docker compose -f docker/compose.yaml run --rm --no-deps python-tests)", + "PowerShell(docker compose -f docker/compose.yaml run --rm --no-deps php-tests)", + "PowerShell(docker compose -f docker/compose.yaml run --rm --no-deps php-tests sh -ec *)", + "PowerShell(docker compose -f docker/compose.yaml run --rm --no-deps php-integration)", + "PowerShell(docker compose -f docker/compose.yaml run --rm --no-deps integration)", + "PowerShell(docker compose -f docker/compose.yaml run --rm --no-deps php-example)", + "PowerShell(docker compose -f docker/compose.yaml run --rm --no-deps mcp-fetcher)", + "PowerShell(docker compose -f docker/compose.yaml run --rm --no-deps mcp-tests)", + "PowerShell(docker compose -f docker/compose.yaml up -d worker frankenphp fault-worker hostile)", + "PowerShell(docker compose -f docker/compose.yaml up -d worker frankenphp)", + "PowerShell(docker compose -f docker/compose.yaml down)", + "PowerShell(docker compose -f docker/compose.yaml down *)", + "PowerShell(docker compose -f docker/compose.yaml --profile * down)", + "PowerShell(docker compose -f docker/compose.yaml --profile * down *)", + "PowerShell(docker compose -f docker/compose.yaml logs *)", + "PowerShell(docker compose -f docker/compose.yaml --profile * logs *)", + "PowerShell(docker compose -f docker/compose.yaml ps *)", + "PowerShell(docker compose -f docker/compose.yaml --profile * ps *)", + "PowerShell(docker compose version)", + "PowerShell(docker version)", + "PowerShell(docker ps *)", + "PowerShell(git status *)", + "PowerShell(git diff *)", + "PowerShell(git log *)", + "PowerShell(git show *)", + "PowerShell(git grep *)", + "PowerShell(git blame *)", + "PowerShell(git worktree list *)", + "PowerShell(git branch)", + "PowerShell(git branch --show-current)", + "PowerShell(git branch --list *)", + "PowerShell(git branch -a)", + "PowerShell(git branch -r)", + "PowerShell(git branch -v)", + "PowerShell(git branch -vv)", + "PowerShell(gh pr view *)", + "PowerShell(gh pr list *)", + "PowerShell(gh pr checks *)", + "PowerShell(gh pr diff *)", + "PowerShell(gh issue view *)", + "PowerShell(gh issue list *)", + "PowerShell(gh run view *)", + "PowerShell(gh run list *)", + "PowerShell(gh workflow view *)", + "PowerShell(gh workflow list *)" + ], + "ask": [ + "Bash(gh workflow run *)", + "Bash(gh pr create *)", + "Bash(gh pr merge *)", + "Bash(gh api -f*)", + "Bash(gh api -F*)", + "Bash(gh api * -f*)", + "Bash(gh api * -F*)", + "Bash(gh api *--field*)", + "Bash(gh api *--raw-field*)", + "Bash(gh api *--input*)", + "Bash(docker compose -f docker/compose.yaml run --rm --no-deps fetcher)", + "PowerShell(gh workflow run *)", + "PowerShell(gh pr create *)", + "PowerShell(gh pr merge *)", + "PowerShell(gh api -f*)", + "PowerShell(gh api -F*)", + "PowerShell(gh api * -f*)", + "PowerShell(gh api * -F*)", + "PowerShell(gh api *--field*)", + "PowerShell(gh api *--raw-field*)", + "PowerShell(gh api *--input*)", + "PowerShell(docker compose -f docker/compose.yaml run --rm --no-deps fetcher)" + ], + "deny": [ + "Read(**/.env)", + "Read(**/.env.*)", + "Read(**/*.key)", + "Read(**/*.pem)", + "Read(~/.claude/.credentials.json)", + "Bash(gh release *)", + "Bash(gh pr merge *--auto*)", + "Bash(gh pr merge *--admin*)", + "Bash(gh api *-X*)", + "Bash(gh api *--method*)", + "Bash(git push --force *)", + "Bash(git push -f *)", + "Bash(git push *--force*)", + "Bash(git push * -f)", + "Bash(git push * -f *)", + "Bash(git push * +*)", + "Bash(git push * main)", + "Bash(git push * *:main)", + "Bash(git push *--tags*)", + "Bash(git push *--follow-tags*)", + "Bash(git push *refs/tags/*)", + "Bash(git push *--mirror*)", + "Bash(git push *--delete*)", + "Bash(git tag *)", + "Bash(git grep *-O*)", + "Bash(git grep *--open-files-in-pager*)", + "Bash(git * --output*)", + "Bash(git * --output=*)", + "Bash(git diff *--no-index*)", + "Bash(git push *refs/heads/main*)", + "PowerShell(gh release *)", + "PowerShell(gh pr merge *--auto*)", + "PowerShell(gh pr merge *--admin*)", + "PowerShell(gh api *-X*)", + "PowerShell(gh api *--method*)", + "PowerShell(git push --force *)", + "PowerShell(git push -f *)", + "PowerShell(git push *--force*)", + "PowerShell(git push * -f)", + "PowerShell(git push * -f *)", + "PowerShell(git push * +*)", + "PowerShell(git push * main)", + "PowerShell(git push * *:main)", + "PowerShell(git push *--tags*)", + "PowerShell(git push *--follow-tags*)", + "PowerShell(git push *refs/tags/*)", + "PowerShell(git push *--mirror*)", + "PowerShell(git push *--delete*)", + "PowerShell(git tag *)", + "PowerShell(git grep *-O*)", + "PowerShell(git grep *--open-files-in-pager*)", + "PowerShell(git * --output*)", + "PowerShell(git * --output=*)", + "PowerShell(git diff *--no-index*)", + "PowerShell(git push *refs/heads/main*)" + ] + }, + "hooks": { + "PostToolUse": [ + { + "matcher": "Edit|Write|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "sh \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/repo-rules.sh", + "timeout": 20 + } + ] + } + ] + } +} diff --git a/.claude/skills/check/SKILL.md b/.claude/skills/check/SKILL.md new file mode 100644 index 0000000..4390f3a --- /dev/null +++ b/.claude/skills/check/SKILL.md @@ -0,0 +1,39 @@ +--- +name: check +description: Run what CI's deterministic gate runs (compose python, PHP, syntax, integration and MCP suites) before a PR +--- +Run the steps in order from the repository root and stop at the first failure. Report each step's +result in one line; on a failure, show the error text and fix it before re-running. + +`BRIDGE_TOKEN` must be set to a disposable random string of 32+ characters first; every compose command +refuses to start without it, and the `config` steps print it, so never run them with a real token in the +shell. The MCP step needs `.cache/wheels` from one network-enabled `mcp-fetcher` +run; it is then offline. On Linux, `mkdir -p .cache && chmod 777 .cache` first so container UID 65532 +can write there. + +```bash +docker compose -f docker/compose.yaml --profile test config +docker compose -f docker/compose.yaml run --rm --no-deps python-tests +docker compose -f docker/compose.yaml run --rm --no-deps php-tests +docker compose -f docker/compose.yaml run --rm --no-deps php-tests sh -ec 'find src examples tests/php -name "*.php" -print0 | xargs -0 -n1 php -l' +docker compose -f docker/compose.yaml up -d worker frankenphp fault-worker hostile +docker compose -f docker/compose.yaml run --rm --no-deps php-integration +docker compose -f docker/compose.yaml run --rm --no-deps integration +docker compose -f docker/compose.yaml --profile test down +docker compose -f docker/compose.yaml --profile mcp --profile mcp-fetch --profile mcp-model config +docker compose -f docker/compose.yaml run --rm --no-deps mcp-fetcher +docker compose -f docker/compose.yaml run --rm --no-deps mcp-tests +docker compose -f docker/compose.yaml --profile mcp --profile mcp-fetch down +``` + +This runs the PHP steps on compose's default image, PHP 8.5. CI runs them on 8.2, 8.3, 8.4 and 8.5 +and first checks that the image's PHP minor is the one the matrix expects; to reproduce another branch, +set `BRIDGE_PHP_IMAGE` to its pinned image from `.github/workflows/tests.yml` and rerun the PHP and +integration steps. Report the other three as "left to CI". If the integration step fails, read +`docker compose -f docker/compose.yaml logs --tail 100` before `down`. + +The real-model smoke tests (`model-smoke.yml`) are not part of this: they download about 330 MB and run +only for a change on that workflow's `paths:` list, through `fetcher`, `model-test` and +`model-integration` as README describes. + +This skill does not merge: merging is through the PR, under AGENTS.md's conditions. diff --git a/.gitattributes b/.gitattributes index 6313b56..ea52120 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,3 @@ * text=auto eol=lf +# Hook scripts run under sh, which breaks on CRLF line endings. +.claude/hooks/* text eol=lf diff --git a/.gitignore b/.gitignore index b043197..5db994f 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,7 @@ __pycache__/ .DS_Store .idea/ .vscode/ + +# Personal AI notes and settings +CLAUDE.local.md +.claude/settings.local.json diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8a603a1 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,93 @@ +# PHP-Python AI Bridge + +A typed PHP client for a small Python service that runs bounded AI tasks (rerank, embed, redact), one +process per job, with a FrankenPHP worker-mode example and a stdio MCP server as a second client of the +same HTTP protocol. Experimental: jobs live in memory, nothing is durable, no production claim is made. +README has the overview; `docs/protocol.md`, `docs/integration.md`, `docs/mcp.md`, `docs/security.md` +and `docs/testing.md` are the guides. Everything builds, tests and downloads inside `docker/compose.yaml`. + +## Layout + +| Path | Holds | +|---|---| +| `src/` | The PHP client (`PhpAiBridge\`): `Client`, `Job`, the typed results, `BridgeException`. PHP `>=8.2 <8.6`, `ext-curl` and `ext-json` only. | +| `worker/ai_bridge/` | The Python 3.12 service: `server.py` (HTTP), `jobs.py` (store, limits, retention), `tasks.py` (allowlist, validation, backends, `MODEL_NAMES`). | +| `mcp_server/bridge_mcp/` | The stdio MCP server: `tools.py` (the tool list and `BACKENDS`), `protocol.py` (its client of the worker), `index.py` (the builder behind `bridge_search`). | +| `examples/` | Local autoloader, the CLI example, the FrankenPHP route and Caddyfile. | +| `scripts/` | Model fetcher, benchmark, NER measurement, MCP stdio launchers, the LLM evaluation runner. | +| `tests/php/` | `run.php` (offline contract checks), `integration.php` (the client against the real worker and broken peers over HTTP), `model.php` (ONNX smoke). | +| `tests/python/` | The worker's unittest suite, including the workflows' runner routing and the post-edit hook. | +| `tests/mcp/` | The MCP server's suite, offline against hash-pinned wheels; its fixtures and LLM evaluations. | +| `tests/*.py` | Cross-boundary tests: `integration.py` (through the FrankenPHP worker), `model_integration.py`, `mcp_model_probe.py`, and `hostile_server.py`, a broken peer. | +| `docker/compose.yaml` | Every service, profile and limit. | +| `.github/workflows/` | `tests.yml`, the deterministic gate, and `model-smoke.yml`, the real models (not required). | + +## Build and test + +Docker with Linux containers and Compose; no local PHP or Python. Every compose command needs +`BRIDGE_TOKEN` in the environment (CI uses `test-only-bridge-token-never-use-in-production`; locally any +random string of 32+ characters). This is what `.github/workflows/tests.yml` runs, in order; `/check` runs it: + +```bash +docker compose -f docker/compose.yaml --profile test config +docker compose -f docker/compose.yaml run --rm --no-deps python-tests +docker compose -f docker/compose.yaml run --rm --no-deps php-tests +docker compose -f docker/compose.yaml run --rm --no-deps php-tests sh -ec 'find src examples tests/php -name "*.php" -print0 | xargs -0 -n1 php -l' +docker compose -f docker/compose.yaml up -d worker frankenphp fault-worker hostile +docker compose -f docker/compose.yaml run --rm --no-deps php-integration +docker compose -f docker/compose.yaml run --rm --no-deps integration +docker compose -f docker/compose.yaml --profile test down +docker compose -f docker/compose.yaml --profile mcp --profile mcp-fetch --profile mcp-model config +docker compose -f docker/compose.yaml run --rm --no-deps mcp-fetcher # network once: wheels into .cache/ +docker compose -f docker/compose.yaml run --rm --no-deps mcp-tests +docker compose -f docker/compose.yaml --profile mcp --profile mcp-fetch down +``` + +CI runs the PHP steps on each of PHP 8.2, 8.3, 8.4 and 8.5 with `BRIDGE_PHP_IMAGE` set to the matrix's +pinned image, after first checking that the image really is `EXPECTED_PHP`; the stable `deterministic` +check requires every matrix job and the MCP job. Locally the default image is PHP 8.5; `docs/testing.md` +says how to pick another, what each layer establishes, and where CI runs. `model-smoke.yml` downloads +about 330 MB of models and wheels and is not a required check: run it only for a change on its `paths:` list. + +## Rules of this repository + +`CONTRIBUTING.md` is the rule book and is not repeated here. In short: PHP is `declare(strict_types=1)`, +four spaces, no `ext-mbstring`, and a warning or deprecation fails its tests; the worker path is standard +library only, with model dependencies in `requirements-model.*` and the MCP server's in +`requirements-mcp.*`; every behaviour needs a success case, invalid-input cases and an integration test +where the PHP/Python boundary is involved; a bug fix is a red-green regression test first; the PR body +records which tests actually ran. Never commit `.env`, model weights, caches or credentials; never run +downloaded model code, and never mount credentials into test containers. Keep the Composer PHP range, the CI image matrix, +compose's default image and `docs/testing.md` consistent. Public documentation changes with the contract, +and the figures the MCP tool descriptions quote come from `docs/testing.md`'s measured tables: re-measure +and update both together. Path-scoped detail is in `.claude/rules/`. + +## Working rules + +- **A branch delivers what it is for, nothing else.** One purpose per branch (one issue; a short name such + as `fix/reject-empty-query`) and one PR, whose description lists the files it touches and why. Larger + work is a feature branch: each piece is a branch whose PR targets the feature branch under every + condition below, and the feature branch reaches `main` through one PR reviewed for how the pieces fit. + A problem found on the way is fixed here only if this branch caused it or it stops the change meeting + its acceptance criteria; otherwise it gets an issue and a branch of its own. No refactors, renames or + extras the issue did not ask for. +- **Everything is tested before a merge**: `/check` locally, tests for every behaviour the change adds or + alters, and the `deterministic` check green on GitHub. Only then may an agent merge its PR, and only + through the PR (`gh pr merge --merge --delete-branch --match-head-commit `), when an + independent review ran and its blocking findings are fixed, a security read of the diff has nothing + open, and the head being merged is the commit all of that ran on. Merge `main` in only for a conflict, + or once before those final checks and review. What could not be tested is said in the PR and waits for + the owner. Never `--auto` or `--admin`, never push to `main`: hand over the command. +- **Reviews are text, commits are changes.** A finding is blocking when it is a defect this branch brings + in, a missing test for what it changes, a broken house rule or a security hole; fix it with the smallest + change. Everything else (problems already on `main`, improvements, style) becomes an issue linked from + the PR. Findings and the reason for a fix go into the review on the PR, never into a commit per round + or per finding: before a commit exists, everything found goes into that one commit; after a commit, a + new finding is one more review entry and one more commit for all of it. Unrelated changes still get + their own commits. +- Commit messages as `CONTRIBUTING.md` says (`feat: add ...`, `fix: reject ...`, `test: cover ...`), one + logical change per commit, no emoji and no AI attribution anywhere (no `Co-Authored-By`, no "Generated + with", in commits, PR text, comments or docs). Commits are `Cristian Girlea `: + check `git config user.email` first. +- Tests first, seen failing. Files are LF (`.gitattributes`). Secrets are never read, printed or + committed. Small diffs: don't restyle what you didn't change. Not sure? Ask, with one proposal. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ef1cb7b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,10 @@ +@AGENTS.md + +Claude-specific: path-scoped rules are in `.claude/rules/` (the PHP client, the worker, the MCP server), +`/check` runs what CI runs, and the post-edit hook reports a PHP file without `declare(strict_types=1)` or +with tabs, and a worker module that imports a model dependency at top level: fix it before moving on. +Permissions in `.claude/settings.json` allow the exact compose commands CI runs and read-only git and gh, +ask before `gh workflow run`, `gh pr create`, `gh pr merge` and the model `fetcher`, and deny pushes to +`main`, force pushes and reading `.env`. Your own notes go in `CLAUDE.local.md` (ignored). +Multi-step work: the user-level `/autonomous-delivery` skill and its `delivery-reviewer` agent follow +this file's rules. diff --git a/tests/python/test_repo_rules_hook.py b/tests/python/test_repo_rules_hook.py new file mode 100644 index 0000000..abf2bce --- /dev/null +++ b/tests/python/test_repo_rules_hook.py @@ -0,0 +1,108 @@ +"""The post-edit hook in .claude/hooks/repo-rules.sh reports a PHP file under src/, examples/ or tests/php that lacks +declare(strict_types=1) or indents with tabs, and a worker module that imports a model dependency at module level. +It must stay quiet for everything else: files outside its scope, odd or empty input, and a file that does not exist. +""" + +import json +import os +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] +HOOK = REPO / ".claude" / "hooks" / "repo-rules.sh" + +CLEAN_PHP = " subprocess.CompletedProcess: + text = payload if isinstance(payload, str) else json.dumps({"tool_name": "Edit", "tool_input": payload}) + return subprocess.run([shutil.which("sh") or "sh", str(HOOK)], input=text, capture_output=True, text=True, + env={**os.environ, "CLAUDE_PROJECT_DIR": str(root)}) + + +class RepoRulesHookTests(unittest.TestCase): + def setUp(self): + self.root = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, self.root, True) + for name in ("src", "examples", "tests/php", "worker/ai_bridge", "docs"): + (self.root / name).mkdir(parents=True) + + def write(self, relative: str, text: str) -> Path: + path = self.root / relative + path.write_text(text, encoding="utf-8") + return path + + def test_the_hook_exists_and_is_lf(self): + self.assertTrue(HOOK.is_file()) + self.assertNotIn(b"\r", HOOK.read_bytes()) + + def test_clean_files_pass(self): + for relative, text in (("src/X.php", CLEAN_PHP), ("examples/x.php", CLEAN_PHP), ("tests/php/x.php", CLEAN_PHP), + ("worker/ai_bridge/tasks.py", CLEAN_WORKER)): + with self.subTest(file=relative): + path = self.write(relative, text) + result = run_hook(self.root, {"file_path": str(path)}) + self.assertEqual((result.returncode, result.stderr), (0, "")) + + def test_a_php_file_without_strict_types_and_with_tabs_is_reported_twice(self): + path = self.write("src/X.php", "