Diagnose a Claude Code session's health against the hook daemon SPEC's acceptance criteria, plus setup/pin/buildbuddy-remote-runner/git-hook diagnostics. Use for "did setup go ok", "why isn't bbr working", "why do my commits fail", "selfcheck".
Installs into .claude/skills of the current project.
Are you the author of Web Selfcheck?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/agentydragon-web-selfcheck)
---
name: web_selfcheck
description: >-
Diagnose a Claude Code session's health against the hook daemon SPEC's
acceptance criteria, plus setup/pin/buildbuddy-remote-runner/git-hook diagnostics. Use
for "did setup go ok", "why isn't bbr working", "why do my commits fail",
"selfcheck".
---
# Session Selfcheck
This skill is the **runnable acceptance test** for the Rust hook daemon
specification at `devinfra/claude/claude_hook/SPEC.md`.
## How to use this skill
1. **Read SPEC.md first.** It enumerates every behavior a healthy session
must satisfy, as a numbered list under the `## Observable Acceptance Criteria`
heading; profile-specific criteria are prefixed `CLI profile:` or
`Web profile:`, the rest are common.
The SPEC is the source of truth. If the SPEC and this skill disagree,
the SPEC wins — update the skill.
2. **Detect the profile.** `$DUCKTAPE_CLAUDE_HOOKS_PROFILE` (or the file
path that the daemon was launched with) tells you whether to run the
CLI or Web criteria. Always run the Common criteria.
3. **For each SPEC criterion, run the matching check** from the
"SPEC acceptance checks" section below.
4. **Then run the out-of-SPEC diagnostics** section, which catches
real-world failure modes the SPEC does not (yet) codify.
5. **Produce the report** using the format at the end.
Run all `Bash` commands with `dangerouslyDisableSandbox: true` (needs
network and filesystem access outside the sandbox). Run independent checks
in parallel where possible.
## CRITICAL: observe only — do NOT fix without explicit user approval
This is a **diagnostic skill**. Treat a broken session like a crime scene:
observe, document, and report — do not touch.
**Do NOT run any remediation commands** (e.g. `web_setup.sh`, re-triggering
SessionStart, sourcing env files, installing packages, re-running
`git remote add`) unless the user explicitly says to proceed. If a check
fails, the fix is "the daemon is broken, tell the user" — not "let me
work around it."
**Exception — debugging workarounds**: when the session hooks are
demonstrably broken and you are actively debugging or documenting, the
following lightweight workarounds are acceptable without explicit
approval:
- Committing with hooks bypassed: `git commit --no-verify` to record
diagnostic work while hooks are broken
- Unsetting `BUILDBUDDY_API_KEY` to force local bazel when bbr is broken
- Creating a `bazel` wrapper in the session bin that injects `--bazelrc`
when the session bazelrc exists but the shim is missing
## Log file inventory
When diagnosing a broken session, these are the log files worth reading, in
order of "most likely to contain the smoking gun":
| Path | What's in it |
| ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/tmp/claude-hd/<sid>/daemon.err.log` | **Unhandled hook daemon errors** — check first. |
| `/tmp/claude-hd/<sid>/daemon.log` | Daemon stdout: startup and per-hook diagnostics. |
| `/tmp/claude-hd/<sid>/startup_failure.json` | Written when the `claude-hook` client could not reach/start the daemon. Distinguishes "dispatcher timed out waiting for socket" from "daemon crashed after accepting connection". |
| `~/.claude/session-env/<sid>/sessionstart-hook-0.sh` | The env file the daemon wrote. Presence means SessionStart got far enough to write the agent shell environment. |
| `/tmp/web-setup.log` | `web_setup.sh` output from the most recent run. First line has `web_setup.sh commit: <sha>` — use it to detect stale setup scripts. |
`<sid>` can be resolved with:
```bash
LIVE=$(ps aux | grep 'claude-hook daemon' | grep -v grep | grep -oP '(?<=--sock /tmp/claude-hd/)[^/]+')
echo "$LIVE"
```
or from `$CLAUDE_ENV_FILE` (the basename of its parent directory).
Agent discipline: when a check below fails, dump the relevant log section
verbatim into the report's "Issues & remediation" block. Do not paraphrase
tracebacks — the exact text is what the user needs to correlate with git log.
## SPEC acceptance checks
Each check below corresponds one-to-one with a numbered criterion in
SPEC.md. Cross-reference the SPEC for the authoritative statement of what
the check is verifying.
### Common
**C1 — BUILDBUDDY_API_KEY is present; validity is proven by C3.**
```bash
[ -n "${BUILDBUDDY_API_KEY:-}" ] && echo "present (len=${#BUILDBUDDY_API_KEY})" \
|| echo "FAIL: BUILDBUDDY_API_KEY unset"
```
Presence only — there is **no working lightweight HTTP probe**. The
`remote.buildbuddy.io` gateway returns `415` for the old `GetUser` curl _with
or without_ the key (it rejects the content type before auth), so that probe
cannot distinguish a valid key. The authoritative validity test is the live
RBE build in **C3 / W4**: a `401`/`403` or a BES auth rejection there is the
real signal of a bad key.
**C2 — GITHUB_TOKEN is present and valid.**
```bash
curl -s -H "Authorization: Bearer ${GITHUB_TOKEN}" https://api.github.com/user \
| python3 -c "import sys,json; d=json.load(sys.stdin); print('login:', d.get('login'), 'message:', d.get('message',''))"
```
Pass on web: `login: agentydragon-agent`. Pass on CLI: the user's own
GitHub login. `Bad credentials` = expired/revoked.
**C3 — `bbr build <trivial>` succeeds without TLS or proxy errors.**
```bash
cd /home/user/ducktape
bbr build //devinfra:gazelle --nobuild 2>&1 | tail -5
```
Pass: exit 0 with no `Unable to resolve host`, `certificate`,
`127.0.0.1:*`, or proxy errors.
**C4 — bazelisk shim is active and invocations are session-tagged.**
```bash
LIVE=${CLAUDE_ENV_FILE:+$(basename "$(dirname "$CLAUDE_ENV_FILE")")}
LIVE=${LIVE:-$(ps aux | grep '[c]laude-hook daemon' | grep -oP '(?<=--sock /tmp/claude-hd/)[^/]+' | head -1)}
SESSION_DIR="$HOME/.claude/session-env/$LIVE"
ls -l "$(command -v bazelisk)" # must point into $SESSION_DIR/bin
grep -E 'build_metadata|TAGS' "$SESSION_DIR/bbr.bazelrc" 2>/dev/null
```
Pass: bazelisk resolves inside the session dir, and bbr.bazelrc contains
`session:<id>` metadata.
**C5 — bbr imports the generated session metadata.**
Covered by the `bbr.bazelrc` metadata check above and by checking the
BuildBuddy invocation tags after a real `bbr` invocation.
**C6 — throwaway-commit pre-commit end-to-end.**
```bash
cd /home/user/ducktape
START_BRANCH=$(git rev-parse --abbrev-ref HEAD)
TEST_BRANCH="selfcheck/$(date +%s)"
TEST_FILE=/home/user/ducktape/selfcheck-tmp.txt # kebab-case: avoids the filename-convention hook
trap 'git checkout -q "$START_BRANCH"; git branch -D "$TEST_BRANCH" 2>/dev/null; rm -f "$TEST_FILE"' EXIT
git checkout -q -b "$TEST_BRANCH"
printf 'selfcheck %s\n' "$(date -Iseconds)" > "$TEST_FILE"
git add "$TEST_FILE"
git commit -m "test: selfcheck — delete me" 2>&1 | tail -40
echo "exit: ${PIPESTATUS[0]}"
```
Pass: exit 0 with every hook Passed/Skipped. No commit-msg trailer is required
— a bare commit message is expected to go through.
**C7 — hook daemon logs present, no unhandled exceptions.**
```bash
LIVE=${CLAUDE_ENV_FILE:+$(basename "$(dirname "$CLAUDE_ENV_FILE")")}
LIVE=${LIVE:-$(ps aux | grep '[c]laude-hook daemon' | grep -oP '(?<=--sock /tmp/claude-hd/)[^/]+' | head -1)}
LOG="/tmp/claude-hd/$LIVE/daemon.log"
[ -f "$LOG" ] && grep -cE 'ERROR|Traceback|Exception' "$LOG" || echo MISSING
```
Pass: log exists, zero matches (or only expected warnings — use judgement).
**C8 — OTLP tracing reaches the collector.**
```bash
curl -s -o /dev/null -w "%{http_code}\n" \
-H "Authorization: Bearer ${DUCKTAPE_OTEL_BEARER_TOKEN}" \
-H "Content-Type: application/json" -d '{}' \
https://alloy-otlp.allegedly.works/v1/traces
```
Pass: `200` or `400` (bad proto = auth passed). `401` = token rotated or
missing.
**C9 — `bbr` preserves the analysis cache on a second identical run.**
Low-precision, high-recall sensor with a high false-positive rate (runner
rotation, BB server restart, cache eviction can all cause transient cold
hits). Report the finding but don't act on a single failure. Stop early
rather than spending many minutes retrying.
**Method — cache poisoning**: append a comment to `MODULE.bazel` so the
first build is guaranteed cold, then time an immediately-following
identical build. The SPEC permits occasional cold-hits; only flag if
warm ≈ cold across **two** repeated runs.
```bash
cd /home/user/ducktape
echo "# selfcheck-poison-$(date +%s)" >> MODULE.bazel
T1_START=$(date +%s%N)
bbr build //... --nobuild 2>&1 | tail -3
T1_SEC=$(( ($(date +%s%N) - T1_START) / 1000000000 ))
T2_START=$(date +%s%N)
bbr build //... --nobuild 2>&1 | tail -3
T2_SEC=$(( ($(date +%s%N) - T2_START) / 1000000000 ))
git checkout -- MODULE.bazel
echo "cold=${T1_SEC}s warm=${T2_SEC}s"
```
Interpret: `warm < cold/3` = recycling works. `warm ≈ cold` = likely not
recycling (but re-run before diagnosing — high FP rate). `cold < 5s` =
build graph too small to measure. If consistently warm≈cold across two
runs, inspect `bbapi invocation <id>` for runner IDs.
### CLI only
**CLI1 — `git commit --amend` is blocked by the git shim.**
```bash
(cd /tmp && git init -q selfcheck && cd selfcheck && \
git commit --allow-empty -m init -q 2>/dev/null && \
git commit --amend --no-edit 2>&1 | grep -c '\[git-shim\] BLOCKED')
rm -rf /tmp/selfcheck
```
Pass: `1`.
**CLI2 — `git add -A` / `git add .` is blocked.**
```bash
(cd /tmp && mkdir -p selfcheck2 && cd selfcheck2 && \
git init -q && git add -A 2>&1 | grep -c '\[git-shim\] BLOCKED')
rm -rf /tmp/selfcheck2
```
Pass: `1`.
**CLI3 — `git stash` is blocked (but `list` / `show` allowed).**
```bash
git stash 2>&1 | grep -c '\[git-shim\] BLOCKED'
git stash list 2>&1 | grep -c '\[git-shim\] BLOCKED' # must be 0
```
**CLI4 — direnv bridge propagates `.envrc` exports into Bash tool calls.**
```bash
# Expect a representative env var (e.g. one set only by .envrc) to appear
# after cd into a subproject that has one.
cd /home/user/ducktape && env | grep -c '^DUCKTAPE_CLAUDE_HOOKS_PROFILE='
```
Pass: `1` (or whatever var your `.envrc` exports).
### Web only
**W1 — `kubectl` works as `claude-code-web`; MCP returns the same pods.**
```bash
kubectl -n claude-sandbox get pods 2>&1 | tail -5
```
Then use the connected `haku-console` Kubernetes passthrough MCP tool for the
same namespace and compare. Pass: both succeed and agree; Haku's operator
approval boundary must remain intact.
**W2 — `$GITHUB_TOKEN` identifies as `agentydragon-agent`.**
Covered by C2 on web — no separate check.
**W3 — `bbr build <any target>` works out of the box, no manual remote
setup, no remote picker.**
```bash
cd /home/user/ducktape
# Run interactively — the test fails if bb prints a remote picker prompt
# or errors on missing git config.
timeout 60 bbr build //devinfra:gazelle --nobuild 2>&1 | tail -10
```
Pass: exit 0, no "which remote" prompt, no `Unable to resolve host`, no
`127.0.0.1:*` in the runner's origin URL.
**W5 — Docker (non-goal: the Rust daemon does not set one up).**
```bash
docker info >/dev/null 2>&1 && echo "present" || echo "absent (expected)"
```
The web profile's `setup_docker` key is **ignored** by the Rust daemon — only
the retired Python daemon started Docker (see `devinfra/claude/TODO.md`). So an
absent local Docker daemon is **expected, not a failure**: Docker-dependent
tests run on BuildBuddy RBE workers, not locally. Report `present`/`absent`
informationally; only flag if a workflow genuinely needs local Docker.
## Out-of-SPEC diagnostics
These are not in SPEC.md but catch real-world failure modes. Include them
in the report under a separate "Diagnostics" heading.
**Before running D1/D2**, skim `devinfra/claude/docs/web-setup-debug.md`
— it documents the historical failure modes (SHA-pinned setup URLs, the
Firecracker "pin drift on persistent rootfs" class, the Nix 2.34.3 SIGABRT
masking issue) and is the authoritative reference for
how `web_setup.sh` is supposed to behave. In particular, the
**"Pin drift on persistent rootfs"** section explains why a container
running for more than a day or two can silently have a stale `.#devtools`
closure even though `web_setup.sh` re-runs every session, and gives the
installed `claude-hook` diagnostic below.
### D1 — `web_setup.sh` freshness (web only)
Anthropic reuses Firecracker microVMs; `/tmp/web-setup.log` may be from a
prior session running an older `web_setup.sh`. A stale setup means Nix
devtools and skills may not match the current code.
```bash
ls -la /tmp/web-setup.log 2>/dev/null || echo "MISSING"
tail -3 /tmp/web-setup.log 2>/dev/null # last line should be "Setup complete."
SETUP_COMMIT=$(grep 'web_setup.sh commit:' /tmp/web-setup.log 2>/dev/null | tail -1 | grep -oE '[0-9a-f]{40}')
HEAD_COMMIT=$(git -C /home/user/ducktape rev-parse HEAD)
[ "$SETUP_COMMIT" = "$HEAD_COMMIT" ] && echo "OK" || echo "STALE: setup=$SETUP_COMMIT head=$HEAD_COMMIT"
```
### D2 — Claude hook and statusline artifact freshness
The Nix outputs are `.#claude-hook` (Rust dispatcher) and
`.#claude-statusline` (Python statusline). Their CI artifact pin IDs are
`claude-hook` and `claude-statusline`, respectively. A stale installed Rust
dispatcher can cause session hook failures. There are **two** independent
kinds of staleness to check:
**(a) Artifact pins are stale** — `sync-pins.yml` did not run recently, or
`release.yml` is failing. Check both pin URLs and the corresponding release
and sync workflow runs. The tag suffix is a content digest, not a source
commit; on a **shallow clone** (Claude Code web clones ~50 commits — check
`.git/shallow`), it cannot be compared directly with Git history. The practical
signal for a hook incompatibility is a schema/template error in `daemon.err.log`.
**(b) Installed `.#devtools` closure is behind the pins** — on Firecracker web
sessions with a persistent rootfs, `nix profile install` is a no-op when
devtools is already installed, so the Rust dispatcher and Python statusline
can remain at first-boot versions even though `nix/artifact-pins.json` moved
forward. This is the class of failure described in `devinfra/claude/docs/web-setup-debug.md`
"Pin drift on persistent rootfs". Typical symptom: SessionStart crashes
with `'Undefined' object has no attribute '<field>'` in `daemon.err.log`,
or silently missing env vars because `serde` ignores an unrecognized
`profile.yaml` field.
```bash
# (a) Pin in artifact-pins vs HEAD
python3 -c "
import json, re
pins = json.load(open('/home/user/ducktape/nix/artifact-pins.json'))['pins']
for name in ('claude-hook', 'claude-statusline'):
url = pins.get(name, {}).get('url', '')
m = re.search(re.escape(name) + r'-([0-9a-f]+)', url)
print(name + ':', m.group(1) if m else 'unknown')
"
git -C /home/user/ducktape log --oneline -5 -- devinfra/claude/ nix/artifact-pins.json
# (b) Inspect the installed Rust dispatcher; its version is not the artifact digest.
claude-hook --version
# Check daemon.err.log for template/schema crashes that indicate drift
tail -50 /tmp/claude-hd/*/daemon.err.log 2>/dev/null
```
If (a) the pin is behind HEAD, diff the installed Nix store package
against the repo source for breaking changes (renamed classes, changed
config paths, removed hooks). Check GitHub CI on `agentydragon/ducktape`:
recent `release.yml` and `sync-pins.yml` runs on `devel`.
If (b) the installed wheel is behind the pin, the remediation is to
re-run `bash devinfra/claude/web_setup.sh` — but do **not** do this
unprompted per the "observe only" rule above. Report the drift in the
Issues section with exact commit SHAs and let the user decide.
### D3 — `git remote origin` URL reachability (web only)
Known failure mode: `bb remote` reads `git remote -v` locally and sends
the URL to the cloud runner. If `origin` is `127.0.0.1:*` (Claude Code
web session proxy), the runner can't reach it and `bbr` fails on hook
invocations.
```bash
ORIGIN=$(git -C /home/user/ducktape remote get-url origin)
echo "$ORIGIN" | grep -qE '127\.0\.0\.1|localhost' && echo "WARN: local proxy origin" || echo "OK"
git -C /home/user/ducktape config buildbuddy.remote-bazel-default-remote 2>/dev/null \
&& echo "(buildbuddy remote override is set)" \
|| echo "(no remote override)"
```
## Report Format
```
# Session Selfcheck — <timestamp>
Profile: <CLI/Web> Summary: <healthy / degraded / broken>
## SPEC acceptance criteria
| ID | Check | Status | Detail |
| --- | ------------------------------ | -------- | --------------------- |
| C1 | BUILDBUDDY_API_KEY valid | OK/FAIL | HTTP <code> |
| C2 | GITHUB_TOKEN valid | OK/FAIL | login=... |
| C3 | bbr build trivial | OK/FAIL | ... |
| C4 | bazelisk shim + session tag | OK/FAIL | ... |
| C5 | bbr imports session metadata | OK/FAIL | session:<id> tag |
| C6 | throwaway commit end-to-end | OK/FAIL | ... |
| C7 | daemon log clean | OK/FAIL | N errors |
| C8 | OTLP tracing | OK/FAIL | HTTP <code> |
| C9 | bbr analysis cache warm | OK/WARN/AMBIG | cold=Xs warm=Ys |
| CLI1–4 / W1–4 | ... | ... |
## Out-of-SPEC diagnostics
| ID | Check | Status | Detail |
| -- | ------------------------------ | ------------- | ---------------------- |
| D1 | web_setup.sh freshness | OK/STALE/MISS | setup=<sha> head=<sha> |
| D2 | hook/statusline pin freshness | OK/BEHIND | pin IDs, CI status |
| D3 | origin URL reachable for bbr | OK/WARN | origin=... |
## Issues & remediation
### <issue title>
**Spec criterion violated**: <ID>
**Impact**: <what's broken for the agent>
**Root cause**: <why>
**Fix** (for the user to run, not the skill): <exact commands>
```
Prioritize: SPEC violations first (the daemon is broken), then out-of-SPEC
diagnostics.