Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 88 additions & 25 deletions .claude/hooks/session-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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] $*"; }

Expand All @@ -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
Expand All @@ -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
Expand All @@ -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)"
9 changes: 8 additions & 1 deletion .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 *)",
Expand All @@ -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",
Expand Down Expand Up @@ -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
}
]
}
Expand Down
17 changes: 11 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ build/
.ruff_cache/
.mypy_cache/
.coverage
.coverage.*
coverage.xml
htmlcov/

Expand Down
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
6 changes: 5 additions & 1 deletion scripts/check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions scripts/gate_tool_pins.sh
Original file line number Diff line number Diff line change
@@ -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"
Loading