diff --git a/.claude/hooks/session-start.sh b/.claude/hooks/session-start.sh index 6f6904d8..6f806017 100755 --- a/.claude/hooks/session-start.sh +++ b/.claude/hooks/session-start.sh @@ -2,13 +2,23 @@ # SessionStart hook: provision a Claude Code on the web container so the canonical # gate (./scripts/check.sh) runs green here exactly as it does in CI and locally. # -# Mirrors the deps CI installs (.github/workflows/ci.yml): +# Mirrors the deps CI installs (.github/workflows/ci.yml); shared tool pins live +# in scripts/gate_tool_pins.sh: # - system: libportaudio2 (sounddevice), ffmpeg (--sample stream sources), shellcheck -# - node: markdownlint-cli@0.45.0 (check.sh calls `markdownlint` directly) +# - node: markdownlint-cli + prettier, pinned to CI's versions +# - go: actionlint + gitleaks (Go binaries, no PyPI/npm wheel) — without them +# check.sh silently self-skips those gates here and the failure only +# surfaces in CI # - python: `uv sync` to materialize the locked dev environment up front # +# Hook stdout is injected into the agent's context at session start, so emit one +# short line per step and send everything verbose to $LOG — a past session burned +# ~38KB of context on raw apt-get output. +# # Only runs in the remote (web) environment — local dev machines already have -# these and shouldn't be reprovisioned. Idempotent and non-interactive. +# these and shouldn't be reprovisioned. Idempotent and non-interactive. Every step +# soft-fails with a warning: a partially provisioned container is still usable, +# and check.sh self-skips whatever is missing (CI still enforces it). set -euo pipefail # Web-only: skip entirely on local machines. @@ -18,8 +28,13 @@ fi cd "${CLAUDE_PROJECT_DIR:-$(cd "$(dirname "$0")/../.." && pwd)}" -# Pin markdownlint-cli to the version CI uses so lint results match exactly. -MARKDOWNLINT_VERSION="0.45.0" +# Tool pins are shared with .github/workflows/ci.yml so lint results match CI +# exactly; bump them in scripts/gate_tool_pins.sh, not here. +# shellcheck source=scripts/gate_tool_pins.sh +. scripts/gate_tool_pins.sh + +LOG="/tmp/session-start.log" +: >"$LOG" log() { echo "[session-start] $*"; } @@ -31,35 +46,75 @@ command -v ffmpeg >/dev/null 2>&1 || need_apt=1 command -v shellcheck >/dev/null 2>&1 || need_apt=1 ldconfig -p 2>/dev/null | grep -q portaudio || need_apt=1 if [ "$need_apt" = "1" ]; then - log "installing system deps (libportaudio2, ffmpeg, shellcheck)" export DEBIAN_FRONTEND=noninteractive # `apt-get update` can exit non-zero when unrelated third-party PPAs are # unreachable; the main Ubuntu archive (which has these packages) still # refreshes, so tolerate that failure and let the install be the real signal. - apt-get update -qq || log "apt-get update reported errors (likely unrelated PPAs); continuing" - # Don't let a missing system package abort the whole session — a partially - # provisioned container is still usable; check.sh self-skips shellcheck. - apt-get install -y --no-install-recommends libportaudio2 ffmpeg shellcheck \ - || log "WARNING: apt-get install failed; some system deps may be missing" + apt-get update -qq >>"$LOG" 2>&1 || log "apt-get update reported errors (likely unrelated PPAs); continuing" + if apt-get install -y --no-install-recommends libportaudio2 ffmpeg shellcheck >>"$LOG" 2>&1; then + log "installed system deps (libportaudio2, ffmpeg, shellcheck)" + else + log "WARNING: apt-get install failed; some system deps may be missing (see $LOG)" + fi else log "system deps already present" fi -# 2. markdownlint CLI (Node) — check.sh invokes `markdownlint` as a global binary, -# not through uv, so it must be on PATH. -if ! command -v markdownlint >/dev/null 2>&1; then - log "installing markdownlint-cli@${MARKDOWNLINT_VERSION}" - npm install -g "markdownlint-cli@${MARKDOWNLINT_VERSION}" -else +# 2. Node lint CLIs — check.sh invokes `markdownlint` and `prettier` as global +# binaries, not through uv, so they must be on PATH at CI's pinned versions +# (the base image may ship a different prettier, and formatting output can +# differ across versions). +if command -v markdownlint >/dev/null 2>&1; then log "markdownlint already present" +elif npm install -g "markdownlint-cli@${MARKDOWNLINT_VERSION}" >>"$LOG" 2>&1; then + log "installed markdownlint-cli@${MARKDOWNLINT_VERSION}" +else + log "WARNING: markdownlint install failed; check.sh's markdownlint gate will error (see $LOG)" +fi +if [ "$(prettier --version 2>/dev/null || true)" = "$PRETTIER_VERSION" ]; then + log "prettier ${PRETTIER_VERSION} already present" +elif npm install -g "prettier@${PRETTIER_VERSION}" >>"$LOG" 2>&1; then + log "installed prettier@${PRETTIER_VERSION}" +else + log "WARNING: prettier install failed; check.sh self-skips its gate (CI still runs it; see $LOG)" fi -# 3. Python environment — materialize the locked dev env so the first `uv run` -# doesn't pay the full sync cost mid-task. `uv` syncs the default dev group. -log "syncing uv environment (locked dev group)" -uv sync +# 3. Go gate binaries (actionlint + gitleaks), same pinned versions CI builds. +# GOBIN=/usr/local/bin so they're on PATH for every later shell. `go install` +# can't bake a comparable version string, so idempotency is presence-only — +# fine, since containers are ephemeral and a pin bump rebuilds on cold start. +install_go_tool() { # $1 = binary name, $2 = module@version + if command -v "$1" >/dev/null 2>&1; then + log "$1 already present" + elif GOBIN=/usr/local/bin go install "$2" >>"$LOG" 2>&1; then + log "installed $1 ($2)" + else + log "WARNING: go install $2 failed; check.sh self-skips the $1 gate (CI still runs it; see $LOG)" + fi +} +if command -v go >/dev/null 2>&1; then + install_go_tool actionlint "$ACTIONLINT_MODULE" + install_go_tool gitleaks "$GITLEAKS_MODULE" +else + log "go not found; skipping actionlint/gitleaks (check.sh self-skips them; CI still runs them)" +fi -# 4. Keep the session branch current. Resumed web containers hold a clone frozen +# 4. Git history — web containers start from a shallow clone, where origin/main +# can exist with NO merge base to the session branch; check.sh's diff-scoped +# tail gates (diff-cover/mutation) then crash with "fatal: ... no merge base" +# instead of self-skipping, and the branch auto-update below can't merge. +# Unshallow up front so both just work. +if [ "$(git rev-parse --is-shallow-repository 2>/dev/null)" = "true" ]; then + if git fetch --unshallow origin main >>"$LOG" 2>&1; then + log "unshallowed clone (merge base with origin/main available for diff gates)" + else + log "WARNING: git fetch --unshallow failed; diff-cover/mutation gates may error (see $LOG)" + fi +else + log "clone already has full history" +fi + +# 5. Keep the session branch current. Resumed web containers hold a clone frozen # at creation time, so two things can go stale: the branch's own remote tip # (pushes from another session/machine) and origin/main (which the diff-scoped # gates — diff-cover, mutation — compare against). Fast-forward to the remote @@ -74,13 +129,13 @@ if [ "$branch" != "HEAD" ] && [ "$branch" != "main" ]; then elif git fetch origin main "$branch" 2>/dev/null || git fetch origin main 2>/dev/null; then if git rev-parse --verify --quiet "origin/$branch" >/dev/null; then if git merge-base --is-ancestor "HEAD" "origin/$branch" && [ "$(git rev-parse HEAD)" != "$(git rev-parse "origin/$branch")" ]; then - git merge --ff-only "origin/$branch" + git merge --ff-only "origin/$branch" >>"$LOG" 2>&1 log "fast-forwarded $branch to its remote tip" fi fi behind=$(git rev-list --count "HEAD..origin/main" 2>/dev/null || echo 0) if [ "$behind" -gt 0 ]; then - if git merge --no-edit origin/main; then + if git merge --no-edit origin/main >>"$LOG" 2>&1; then log "merged origin/main into $branch (was $behind commit(s) behind)" else git merge --abort 2>/dev/null || true @@ -94,4 +149,12 @@ if [ "$branch" != "HEAD" ] && [ "$branch" != "main" ]; then fi fi -log "provisioning complete" +# 6. Python environment — materialize the locked dev env so the first `uv run` +# doesn't pay the full sync cost mid-task. `uv` syncs the default dev group. +if uv sync >>"$LOG" 2>&1; then + log "uv environment synced (locked dev group)" +else + log "WARNING: uv sync failed (see $LOG)" +fi + +log "provisioning complete (full output: $LOG)" diff --git a/.claude/settings.json b/.claude/settings.json index 09db6b35..d1186d4c 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -30,6 +30,9 @@ "Bash(git diff *)", "Bash(git log *)", "Bash(git show *)", + "Bash(git rev-parse *)", + "Bash(git ls-files *)", + "Bash(git grep *)", "Bash(git branch *)", "Bash(git add *)", "Bash(git commit *)", @@ -39,6 +42,9 @@ "Bash(git worktree *)", "Bash(gh pr view *)", "Bash(gh pr list *)", + "Bash(gh pr diff *)", + "Bash(gh pr checks *)", + "Bash(timeout 30 uv run assembly *)", "Bash(gh run view *)", "Bash(gh run list *)", "mcp__assemblyai-docs__search_docs", @@ -73,7 +79,8 @@ "hooks": [ { "type": "command", - "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/session-start.sh" + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/session-start.sh", + "timeout": 600 } ] } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c6e46e61..f19ae03b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,10 +49,13 @@ jobs: - name: System deps (PortAudio + ffmpeg) run: sudo apt-get update && sudo apt-get install -y libportaudio2 ffmpeg - # check.sh lints Markdown and template JS/CSS via Node CLIs; pin to the - # versions used locally. The runner ships Node, so a global npm install suffices. + # check.sh lints Markdown and template JS/CSS via Node CLIs; versions are + # pinned in scripts/gate_tool_pins.sh (shared with the web session-start + # hook). The runner ships Node, so a global npm install suffices. - name: Node lint CLIs - run: npm install -g markdownlint-cli@0.45.0 prettier@3.8.3 + run: | + source scripts/gate_tool_pins.sh + npm install -g "markdownlint-cli@${MARKDOWNLINT_VERSION}" "prettier@${PRETTIER_VERSION}" # check.sh runs every tool through `uv run` / `uv build` for a locked, # reproducible env, so only uv must be on PATH (installed from PyPI to match @@ -63,12 +66,14 @@ jobs: # actionlint and gitleaks are Go binaries (no PyPI wheel), so check.sh self-skips # them locally like shellcheck. Build them here with the runner's preinstalled Go, - # pinned to a release tag, and put GOPATH/bin on PATH so check.sh enforces them. + # pinned via scripts/gate_tool_pins.sh (shared with the web session-start hook), + # and put GOPATH/bin on PATH so check.sh enforces them. # (gitleaks v8's Go module path is still github.com/zricethezav/gitleaks/v8.) - name: Workflow + secret scanners (actionlint, gitleaks) run: | - go install github.com/rhysd/actionlint/cmd/actionlint@v1.7.7 - go install github.com/zricethezav/gitleaks/v8@v8.21.2 + source scripts/gate_tool_pins.sh + go install "$ACTIONLINT_MODULE" + go install "$GITLEAKS_MODULE" echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" - name: Lint, typecheck, test diff --git a/.gitignore b/.gitignore index 2510994d..2865af01 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ build/ .ruff_cache/ .mypy_cache/ .coverage +.coverage.* coverage.xml htmlcov/ diff --git a/AGENTS.md b/AGENTS.md index 0ad77ad4..5132fa5b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,6 +98,13 @@ Lessons that cost iterations getting the patch-coverage and mutation tail gates Lessons that cost time in agent sessions — read before exercising `uv run assembly` by hand: +- **Web/remote containers are fully provisioned at session start** + (`.claude/hooks/session-start.sh`): system deps, `markdownlint`/`prettier`, and the Go + gate binaries (`actionlint`, `gitleaks`) are installed at CI's pinned versions, so + `./scripts/check.sh` enforces the same gates CI does — a gate that "self-skips locally" + should *not* be skipping in a web session. If one is, read `/tmp/session-start.log` to + see what failed to provision. Keep the hook's stdout terse (one line per step) — it is + injected into the agent's context every session. - **Probe network reachability first.** Remote/sandboxed environments often allowlist PyPI but block `api.assemblyai.com` / `streaming.assemblyai.com` / `llm-gateway.assemblyai.com` (`curl -s https://api.assemblyai.com/v2/transcript -H "authorization: $ASSEMBLYAI_API_KEY"` diff --git a/scripts/check.sh b/scripts/check.sh index c72ed423..e7d19877 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -122,7 +122,11 @@ echo "==> shellcheck" # Static-lint this gate script. CI's ubuntu runner ships shellcheck; # locally it's skipped with a notice if not installed. if command -v shellcheck >/dev/null 2>&1; then - shellcheck scripts/check.sh scripts/docker_build_check.sh scripts/cut_release.sh scripts/bump_minor.sh + # -x + --source-path=. let it follow the hook's `. scripts/gate_tool_pins.sh` + # (paths resolve from the repo root, where this script always runs). + shellcheck -x --source-path=. scripts/check.sh scripts/docker_build_check.sh \ + scripts/cut_release.sh scripts/bump_minor.sh scripts/gate_tool_pins.sh \ + .claude/hooks/session-start.sh else echo " shellcheck not found; skipping (CI runs it)" fi diff --git a/scripts/gate_tool_pins.sh b/scripts/gate_tool_pins.sh new file mode 100644 index 00000000..c90df9f2 --- /dev/null +++ b/scripts/gate_tool_pins.sh @@ -0,0 +1,12 @@ +# shellcheck shell=bash +# Single source of truth for the gate's non-Python tool pins. Python tools are +# pinned in pyproject.toml/uv.lock; these four have no PyPI distribution +# (markdownlint/prettier are npm packages, actionlint/gitleaks are Go binaries), +# so their versions live here instead. Sourced by both provisioning paths: +# - .github/workflows/ci.yml (the CI runner) +# - .claude/hooks/session-start.sh (Claude Code on the web containers) +# Bump a pin here and both environments pick it up together. +export MARKDOWNLINT_VERSION="0.45.0" +export PRETTIER_VERSION="3.8.3" +export ACTIONLINT_MODULE="github.com/rhysd/actionlint/cmd/actionlint@v1.7.7" +export GITLEAKS_MODULE="github.com/zricethezav/gitleaks/v8@v8.21.2"