diff --git a/.claude/worktrees/objective-spence b/.claude/worktrees/objective-spence
deleted file mode 160000
index d3194de..0000000
--- a/.claude/worktrees/objective-spence
+++ /dev/null
@@ -1 +0,0 @@
-Subproject commit d3194de0b852752ccd70eb41bd743b2d52319a51
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..14ddc3f
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,38 @@
+# Secrets — never copy into an image layer. Keys are supplied at runtime via
+# Coolify's environment variables, or per-request from the web form.
+.env
+.env.*
+
+# Local virtualenvs and Python build noise. Patterns are matched from the
+# build-context root, so nested matches need an explicit **/ prefix.
+venv/
+.venv/
+**/__pycache__/
+**/*.py[cod]
+**/*.egg-info/
+build/
+dist/
+
+# Version control and editor/tooling state
+.git/
+.gitignore
+.github/
+.claude/
+.idea/
+.vscode/
+
+# Runtime output — regenerated per audit, never part of the image
+output/
+reports/
+test_results/
+
+# Sample inputs and reference material. Only needed for the CLI pipeline
+# (entry_points/run_pipeline.py), not for serving the app. Drop these lines
+# if you want to run the CLI inside the container.
+test_files/
+semantic_checklist/
+docs/
+*.ipynb
+
+# Deployment docs — not needed inside the image
+DEPLOY.md
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..774f495
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,206 @@
+name: CI
+
+on:
+ pull_request:
+ branches: [main]
+
+# A new push to the same PR cancels the previous run.
+concurrency:
+ group: ci-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+jobs:
+ checks:
+ name: Dependencies, imports, pipeline
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@v7
+
+ - name: Install uv
+ uses: astral-sh/setup-uv@v9.0.0
+ with:
+ version: "0.11.21"
+ enable-cache: true
+
+ # Fails if pyproject.toml and uv.lock disagree — i.e. someone changed a
+ # dependency without re-running `uv lock`.
+ - name: uv.lock is up to date
+ run: uv lock --check
+
+ - name: Install locked dependencies
+ run: uv sync --frozen
+
+ # requirements.txt is a generated export, not a hand-maintained list.
+ # Regenerate it and fail if the committed copy differs.
+ - name: requirements.txt matches uv.lock
+ run: |
+ uv export --format requirements.txt --no-dev --no-emit-project \
+ --no-hashes -o requirements.txt
+ if ! git diff --exit-code --stat -- requirements.txt; then
+ echo "::error::requirements.txt is out of date. Regenerate it with the command in its header and commit the result."
+ exit 1
+ fi
+
+ - name: Entry points import
+ run: |
+ uv run python -c "
+ import entry_points.api_server, entry_points.run_pipeline, entry_points.generate_report
+ from vision_aid.ingestion.file_crawler import fetch_page, fetch_pages_nested
+ print('all entry points import cleanly')
+ "
+
+ # Exercises extractors, slicers, prompt templates and the programmatic
+ # checkers end to end. --dry-run makes no API calls, so this needs no key
+ # and costs nothing.
+ - name: Pipeline dry run
+ run: |
+ uv run python entry_points/run_pipeline.py \
+ --html test_files/dat_visionaid_home.html \
+ --dry-run --output-dir ./ci-output
+
+ - name: Pipeline produced expected output
+ run: |
+ uv run python - <<'PY'
+ import json, pathlib, sys
+
+ manifest = json.loads(pathlib.Path("ci-output/manifest.json").read_text())
+ prompts = list(pathlib.Path("ci-output/prompts").glob("*.json"))
+ findings = json.loads(
+ pathlib.Path("ci-output/programmatic_findings.json").read_text()
+ )
+
+ problems = []
+ if not manifest["dry_run"]:
+ problems.append("manifest says this was not a dry run")
+ if manifest["total_input_tokens"] or manifest["total_output_tokens"]:
+ problems.append("a dry run consumed tokens — an API call escaped")
+ if not manifest["prompts_dry_run"]:
+ problems.append("no prompts were generated")
+ if not findings:
+ problems.append("programmatic checkers found nothing")
+ if len(prompts) != len(manifest["prompts_dry_run"]):
+ problems.append(
+ f"{len(prompts)} prompt files vs "
+ f"{len(manifest['prompts_dry_run'])} in manifest"
+ )
+
+ if problems:
+ for p in problems:
+ print(f"::error::{p}")
+ sys.exit(1)
+
+ print(
+ f"OK — {len(manifest['prompts_dry_run'])} prompts generated, "
+ f"{len(manifest['prompts_skipped'])} skipped, "
+ f"{len(findings)} programmatic findings"
+ )
+ PY
+
+ # index.html carries all of the front-end logic inline, so a syntax error
+ # there ships a broken page with nothing else to catch it.
+ - name: index.html JavaScript parses
+ run: |
+ python3 - <<'PY'
+ import pathlib, re
+ html = pathlib.Path("index.html").read_text(encoding="utf-8", errors="replace")
+ blocks = re.findall(r"", html, re.DOTALL)
+ assert blocks, "no