From 310a42cee197290ae75363d5aa46173b2536c315 Mon Sep 17 00:00:00 2001 From: bob Date: Fri, 26 Jun 2026 21:46:21 -0400 Subject: [PATCH 1/3] feat(hooks): run stop-run-checks inside a compose service when a CHECKS entry names one Co-Authored-By: Claude Opus 4.8 --- .../git-workflow/hooks/stop-run-checks.sh | 65 ++++++++++--- test/stop-run-checks/test-container-checks.sh | 91 +++++++++++++++++++ 2 files changed, 144 insertions(+), 12 deletions(-) create mode 100644 test/stop-run-checks/test-container-checks.sh diff --git a/templates/git-workflow/hooks/stop-run-checks.sh b/templates/git-workflow/hooks/stop-run-checks.sh index 364744f..bb9553c 100644 --- a/templates/git-workflow/hooks/stop-run-checks.sh +++ b/templates/git-workflow/hooks/stop-run-checks.sh @@ -46,8 +46,15 @@ except Exception: ' 2>/dev/null || echo 0)" if [ "$BG_COUNT" -gt 0 ]; then exit 0; fi -# label|command — labels are display-only, commands come from cc-configure. -# Leave a value empty after the `|` to skip that check entirely. +# label|command or label|command|service +# - labels are display-only; commands come from cc-configure. +# - leave the command empty (label|) to skip a check entirely. +# - add a docker-compose service as a 3rd field to run that check INSIDE the +# container (reuses your existing service): runs `docker compose exec -T +# ` if it's up, else `docker compose run --rm +# `; skips silently if docker/compose or the service is absent. +# Service names are single tokens; a containerized command can't contain `|`. +# Example: "test|pytest|backend" "typecheck|pnpm typecheck|frontend" CHECKS=( "typecheck|{{cmd_typecheck}}" "lint|{{cmd_lint}}" @@ -71,24 +78,58 @@ manifest_for() { esac } +# Echo a working `docker compose` invocation, or return non-zero when the +# docker compose v2 CLI isn't usable here (so container checks skip, fail-open). +compose() { + command -v docker >/dev/null 2>&1 || return 1 + docker compose version >/dev/null 2>&1 || return 1 + echo "docker compose" +} + REPORT="" for entry in "${CHECKS[@]}"; do label="${entry%%|*}" - cmd="${entry#*|}" + rest="${entry#*|}" + + # Optional 3rd field = compose service. An entry is container-bound iff it + # has >=2 pipes AND its final field is a bare token (compose service names + # match [A-Za-z0-9._-]+). Otherwise it's a host command — `cmd` is everything + # after the first pipe, so host commands may still contain a literal `|`. + pipes="$(printf '%s' "$entry" | tr -cd '|' | wc -c | tr -d ' ')" + last="${entry##*|}" + if [ "$pipes" -ge 2 ] && printf '%s' "$last" | grep -qxE '[A-Za-z0-9._-]+'; then + service="$last" + cmd="${rest%|*}" + else + service="" + cmd="$rest" + fi # Skip empty (user opted out of this check during intake). [ -z "$cmd" ] && continue - # Skip if the first binary in the command isn't on PATH. - first="$(printf '%s' "$cmd" | awk '{print $1}')" - if ! command -v "$first" >/dev/null 2>&1; then continue; fi - - # Skip if the stack manifest hasn't landed yet. Project-relative path — - # the cd above pins us to CLAUDE_PROJECT_DIR. - manifest="$(manifest_for "$first")" - if [ -n "$manifest" ] && [ ! -f "$manifest" ]; then continue; fi + if [ -z "$service" ]; then + # --- HOST branch (unchanged) --- + first="$(printf '%s' "$cmd" | awk '{print $1}')" + if ! command -v "$first" >/dev/null 2>&1; then continue; fi + manifest="$(manifest_for "$first")" + if [ -n "$manifest" ] && [ ! -f "$manifest" ]; then continue; fi + run_cmd="$cmd" + else + # --- CONTAINER branch --- + cc="$(compose)" || { echo "[stop-check] ${label}: docker compose unavailable; skipping container check" >&2; continue; } + if ! $cc config --services 2>/dev/null | grep -qxF "$service"; then + echo "[stop-check] ${label}: compose service '${service}' not defined; skipping" >&2 + continue + fi + if $cc ps --status running --services 2>/dev/null | grep -qxF "$service"; then + run_cmd="$cc exec -T ${service} ${cmd}" + else + run_cmd="$cc run --rm ${service} ${cmd}" + fi + fi - out=$(eval "$cmd" 2>&1) && status=0 || status=$? + out=$(eval "$run_cmd" 2>&1) && status=0 || status=$? if [ $status -eq 0 ]; then REPORT="${REPORT}[stop-check] ${label}: OK"$'\n' else diff --git a/test/stop-run-checks/test-container-checks.sh b/test/stop-run-checks/test-container-checks.sh new file mode 100644 index 0000000..895f253 --- /dev/null +++ b/test/stop-run-checks/test-container-checks.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# F3: a CHECKS entry with a 3rd field (compose service) runs the check inside +# that service — `docker compose exec -T ` when it's running, else +# `docker compose run --rm ` — bypassing the host-PATH/manifest guards. +# Infra-not-ready (no docker compose / service undefined) skips silently; a +# non-zero container exit is reported as FAIL. A 2-field entry stays host-side +# and never touches docker. Uses a PATH-prepended `docker` stub (no real Docker). +set -euo pipefail + +tmp=$(mktemp -d) +trap "rm -rf $tmp" EXIT + +# Scaffold a real project so the template renders with cmd_* substitutions. +python3 configure.py --persona solo-experienced --yes --dir "$tmp" >/dev/null +hook="$tmp/.claude/hooks/stop-run-checks.sh" +[ -x "$hook" ] || { echo "FAIL: hook not scaffolded at $hook"; exit 1; } + +# Make `test` container-bound (service=backend); blank the others so only it runs. +python3 - "$hook" <<'PY' +import sys, pathlib +p = pathlib.Path(sys.argv[1]); t = p.read_text() +t = t.replace('"typecheck|pnpm typecheck"', '"typecheck|"') +t = t.replace('"lint|pnpm lint"', '"lint|"') +t = t.replace('"test|pnpm test"', '"test|pytest|backend"') +p.write_text(t) +PY + +# Fake docker: logs compose exec/run args to $DOCKER_LOG, touches $DOCKER_MARKER, +# exits $FAKE_EXIT. `compose version` honors $FAKE_NO_COMPOSE; `config --services` +# echoes $FAKE_SERVICES; `ps` echoes $FAKE_RUNNING. +fakebin="$tmp/bin"; mkdir -p "$fakebin" +cat > "$fakebin/docker" <<'EOF' +#!/usr/bin/env bash +[ "${1:-}" = "compose" ] || exit 0 +shift +case "${1:-}" in + version) [ -n "${FAKE_NO_COMPOSE:-}" ] && exit 1; exit 0 ;; + config) printf '%s\n' ${FAKE_SERVICES:-backend frontend}; exit 0 ;; + ps) [ -n "${FAKE_RUNNING:-}" ] && printf '%s\n' ${FAKE_RUNNING}; exit 0 ;; + exec|run) printf '%s\n' "$*" >> "$DOCKER_LOG"; touch "$DOCKER_MARKER"; exit "${FAKE_EXIT:-0}" ;; + *) exit 0 ;; +esac +EOF +chmod +x "$fakebin/docker" + +export DOCKER_LOG="$tmp/docker.log" DOCKER_MARKER="$tmp/docker.marker" +run_hook() { # extra env as KEY=VAL args; prints the hook's stdout (the report) + rm -f "$DOCKER_LOG" "$DOCKER_MARKER" + env "$@" CLAUDE_PROJECT_DIR="$tmp" PATH="$fakebin:$PATH" bash "$hook" /dev/null || true +} + +# A: service defined + NOT running -> docker compose run --rm backend +run_hook FAKE_SERVICES="backend frontend" FAKE_RUNNING="" >/dev/null +grep -q "run --rm backend" "$DOCKER_LOG" \ + || { echo "FAIL A: expected 'run --rm backend'; log: $(cat "$DOCKER_LOG" 2>/dev/null)"; exit 1; } + +# B: service running -> docker compose exec -T backend +run_hook FAKE_SERVICES="backend frontend" FAKE_RUNNING="backend" >/dev/null +grep -q "exec -T backend" "$DOCKER_LOG" \ + || { echo "FAIL B: expected 'exec -T backend'; log: $(cat "$DOCKER_LOG" 2>/dev/null)"; exit 1; } + +# C: service NOT defined -> skip (no exec/run) +run_hook FAKE_SERVICES="frontend" FAKE_RUNNING="" >/dev/null +[ -e "$DOCKER_MARKER" ] && { echo "FAIL C: ran a container cmd for an undefined service"; exit 1; } || true + +# D: docker compose plugin missing -> skip +run_hook FAKE_NO_COMPOSE=1 >/dev/null +[ -e "$DOCKER_MARKER" ] && { echo "FAIL D: ran a container cmd with no compose plugin"; exit 1; } || true + +# E: container command exits non-zero -> reported as FAIL +report=$(run_hook FAKE_SERVICES="backend frontend" FAKE_RUNNING="backend" FAKE_EXIT=1) +printf '%s' "$report" | grep -q "FAIL" \ + || { echo "FAIL E: non-zero container check not reported; report: $report"; exit 1; } + +# F: a 2-field entry stays host-side and never invokes docker +python3 - "$hook" <<'PY' +import sys, pathlib +p = pathlib.Path(sys.argv[1]); t = p.read_text() +p.write_text(t.replace('"test|pytest|backend"', '"test|hostonly"')) +PY +cat > "$fakebin/hostonly" </dev/null +[ -e "$tmp/host-ran" ] || { echo "FAIL F: 2-field host check did not run host-side"; exit 1; } +[ -e "$DOCKER_MARKER" ] && { echo "FAIL F: 2-field host check invoked docker"; exit 1; } || true + +echo "PASS: container checks use exec/run by service state, skip when infra absent, report failures, and 2-field stays host-side" From bd2c8e5472b2a87ca6fe98e594f6e34631fecd4d Mon Sep 17 00:00:00 2001 From: bob Date: Fri, 26 Jun 2026 21:58:20 -0400 Subject: [PATCH 2/3] feat(hooks): point the [ STACK WARNINGS ] note at the stop-run-checks service field; changelog Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 2 ++ configure.py | 9 ++++++--- test/cc-manifest/test-stack-reality-preflight.sh | 2 ++ 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8473e76..0358897 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to this project. Format: [Keep a Changelog](https://keepacha ## Unreleased +- **feat(hooks): `stop-run-checks` can run a check inside its docker-compose service (dogfood F3).** A containerized project's check loop no-op'd because the toolchain lives in the image, not on the host PATH (F2 warned about this). A `CHECKS` entry now takes an optional 3rd field — `label|command|service` — and when a service is named the Stop hook runs the check inside it: `docker compose exec -T ` if the service is up, else `docker compose run --rm ` (a throwaway instance of the *existing* service — no new container, no host rebuild, no path translation). The host-PATH/manifest guards are bypassed for container checks; infra-not-ready (no docker/compose, or service undefined) skips silently (fail-open), and a non-zero container exit is reported as a normal check FAIL. Two-field `label|command` entries — including host commands containing a literal `|` — are unchanged; an entry is container-bound only when it has ≥2 pipes and a bare-token final field. The F2 `[ STACK WARNINGS ]` note now points at this field. New `test/stop-run-checks/test-container-checks.sh` (run-vs-exec by service state, skip when compose/service absent, FAIL on non-zero, 2-field stays host-side) via a `docker` stub. **Deferred follow-up:** a conditional intake field that detects `docker-compose.yml` and pre-populates the service per check (this format is the substrate). No `tested_up_to` / `CC_VERSION` bump. + - **fix(preflight): host-PATH gate handles path-like binaries (`./gradlew`); drop redundant `import shutil`; tighten the retrofit-migration test (review follow-ups to #88/#89).** Three non-blocking advisories from the split dogfood PRs: **(1)** `check_stack_reality`'s container-note gate used `shutil.which(b)`, which always returns `None` for a path-like binary like `./gradlew` (a project-local wrapper, never on PATH) — so a Gradle-wrapper project with a `Dockerfile`/compose and no root `build.gradle` got a spurious "toolchain lives in the container" note. The gate now file-checks path-like binaries (`(target_dir / b).exists()`) and only consults `shutil.which` for bare names. **(2)** the inline `import shutil` inside `check_stack_reality` was redundant (shutil is imported at module top) and is removed. **(3)** `test/retrofit-hooks/test-sessionstart-matcher-migration.sh` case 2 now asserts the marker-clear migrates *out* of a user's mixed matcherless group (appearing exactly once, under `startup|clear`), not merely that the user hook survives. New `./gradlew` case in `test/cc-manifest/test-stack-reality-preflight.sh`. No behavior change for non-path stacks. - **fix(commands): scope the microbit-enforcer SessionStart marker-clear to fresh-slate sources so a long session keeps its freezes (dogfood F1).** The `freeze`/`guard`/`careful` micro-behaviors drop `.claude/.frozen` / `.guarded` / `.careful` markers that the `microbit-enforcer.sh` PreToolUse hook reads to reject Write/Edit. The companion SessionStart hook that clears those markers shipped with **no `matcher`**, so it fired on *every* SessionStart source — including `resume` and `compact`, not just `startup`. Net effect: in a long session you `/freeze` a file, context compacts (a SessionStart with `source: compact`) or you `--resume`, and the marker is silently deleted mid-task — the enforcer stops protecting the file exactly when you'd most want it. **Fix:** the SessionStart entry in `templates/commands/microbit-enforcer/settings-patch.json` now carries `"matcher": "startup|clear"` (sources verified against `code.claude.com/docs/en/hooks-guide` — SessionStart sources are `startup`, `resume`, `clear`, `compact`, and the matcher filters on source), so markers clear only on a brand-new session or `/clear` and persist across `--resume`/`--continue` and compaction. The freeze/guard/careful `SKILL.md` lifecycle notes, the `microbit-enforcer.sh` header, and the `templates/core/CLAUDE.md` gitignore-block note are corrected to match — across both the templates and the `python-uv-fastapi` example mirror. New `test/microbit-enforcer/test-sessionstart-matcher.sh` asserts the merged settings carry the matcher. **Retrofit migration:** existing installs carry a *matcherless* SessionStart marker-clear group, and `_merge_hook_groups` keys by matcher — so without handling, `cc-configure --retrofit` would append the new `startup|clear` group beside the old one and the stale matcherless group would keep wiping markers on resume/compact, re-negating the fix on every upgrade (the same class as PR #73's standalone→bundled hook bug, for a matcher *change*). The merge now does a per-command pre-pass: for each configurator command it collects the matcher(s) the new template places it under, then strips that command from any existing group under a *different* matcher (migrating it once). This preserves the deliberate same-command-under-multiple-matchers pattern (the mcp drift-check ships under both `startup` and `resume`) and a user's own commands in a mixed group — only the configurator command migrates out. New `test/retrofit-hooks/test-sessionstart-matcher-migration.sh`. Surfaced by dogfooding the configurator into a containerized project. diff --git a/configure.py b/configure.py index f176724..dab3eb1 100755 --- a/configure.py +++ b/configure.py @@ -1079,9 +1079,12 @@ def _off_host(b): if (target_dir / n).exists()), None) if container: warnings.append( - "detected {c} — if your toolchain runs in containers, the " - "format-on-write and stop-run-checks hooks call host tools and " - "will no-op. Point cmd_* at `docker compose exec ...`.".format( + "detected {c} — if your toolchain runs in containers, host-side " + "hooks no-op. Run checks in the container: add the service as a " + "3rd field on the matching CHECKS entry in " + ".claude/hooks/stop-run-checks.sh (e.g. `test|pytest|backend`), " + "which runs `docker compose exec`/`run`. format-on-write stays " + "host-side — install the formatter (ruff/prettier).".format( c=container)) return warnings diff --git a/test/cc-manifest/test-stack-reality-preflight.sh b/test/cc-manifest/test-stack-reality-preflight.sh index 0366b2f..33388c2 100755 --- a/test/cc-manifest/test-stack-reality-preflight.sh +++ b/test/cc-manifest/test-stack-reality-preflight.sh @@ -60,6 +60,8 @@ touch "$d/frontend/package.json" "$d/docker-compose.yml" out=$(run "$d" '{"cmd_typecheck":"pnpm typecheck","cmd_lint":"pnpm lint","cmd_test":"pnpm test"}' none) echo "$out" | grep -q "docker compose exec" \ || { echo "FAIL: containerized + toolchain-off-host should get the container note; got: $out"; exit 1; } +echo "$out" | grep -q "CHECKS entry" \ + || { echo "FAIL: container note should point at the CHECKS service field in stop-run-checks.sh; got: $out"; exit 1; } # 4b. A1: same layout but toolchain IS on host (which=all) -> subdir warn, NO container note out=$(run "$d" '{"cmd_typecheck":"pnpm typecheck","cmd_lint":"pnpm lint","cmd_test":"pnpm test"}' all) From 4c64ee29689c5766d5a2a7c0d035d877ae1eda3c Mon Sep 17 00:00:00 2001 From: bob Date: Sat, 27 Jun 2026 09:41:50 -0400 Subject: [PATCH 3/3] fix(hooks): -T on `compose run` too + probe compose once + robust test-A log assert (PR #91 review) Three non-blocking review advisories: (1) add -T to the run-branch for symmetry with exec and to suppress a 'not a TTY' line in the report on compose < v2.2.0; (2) hoist the compose() probe to a single $CC before the loop instead of once per container-bound check; (3) touch $DOCKER_LOG in the test's run_hook so an unexpected skip yields FAIL A, not a grep file-not-found. Co-Authored-By: Claude Opus 4.8 --- templates/git-workflow/hooks/stop-run-checks.sh | 16 +++++++++++----- test/stop-run-checks/test-container-checks.sh | 7 ++++--- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/templates/git-workflow/hooks/stop-run-checks.sh b/templates/git-workflow/hooks/stop-run-checks.sh index bb9553c..5fc6520 100644 --- a/templates/git-workflow/hooks/stop-run-checks.sh +++ b/templates/git-workflow/hooks/stop-run-checks.sh @@ -86,6 +86,10 @@ compose() { echo "docker compose" } +# Probe the compose CLI once (it's a daemon round-trip); container-bound checks +# below reuse $CC. Empty string ⇒ compose unusable ⇒ those checks skip. +CC="$(compose)" || CC="" + REPORT="" for entry in "${CHECKS[@]}"; do label="${entry%%|*}" @@ -117,15 +121,17 @@ for entry in "${CHECKS[@]}"; do run_cmd="$cmd" else # --- CONTAINER branch --- - cc="$(compose)" || { echo "[stop-check] ${label}: docker compose unavailable; skipping container check" >&2; continue; } - if ! $cc config --services 2>/dev/null | grep -qxF "$service"; then + [ -z "$CC" ] && { echo "[stop-check] ${label}: docker compose unavailable; skipping container check" >&2; continue; } + if ! $CC config --services 2>/dev/null | grep -qxF "$service"; then echo "[stop-check] ${label}: compose service '${service}' not defined; skipping" >&2 continue fi - if $cc ps --status running --services 2>/dev/null | grep -qxF "$service"; then - run_cmd="$cc exec -T ${service} ${cmd}" + # -T on both: hooks run non-interactively, so disable PTY allocation (else + # compose < v2.2.0 prints "input device is not a TTY" into the report). + if $CC ps --status running --services 2>/dev/null | grep -qxF "$service"; then + run_cmd="$CC exec -T ${service} ${cmd}" else - run_cmd="$cc run --rm ${service} ${cmd}" + run_cmd="$CC run --rm -T ${service} ${cmd}" fi fi diff --git a/test/stop-run-checks/test-container-checks.sh b/test/stop-run-checks/test-container-checks.sh index 895f253..ca12a35 100644 --- a/test/stop-run-checks/test-container-checks.sh +++ b/test/stop-run-checks/test-container-checks.sh @@ -46,13 +46,14 @@ chmod +x "$fakebin/docker" export DOCKER_LOG="$tmp/docker.log" DOCKER_MARKER="$tmp/docker.marker" run_hook() { # extra env as KEY=VAL args; prints the hook's stdout (the report) rm -f "$DOCKER_LOG" "$DOCKER_MARKER" + touch "$DOCKER_LOG" # always exists so a skip yields FAIL X, not a grep error env "$@" CLAUDE_PROJECT_DIR="$tmp" PATH="$fakebin:$PATH" bash "$hook" /dev/null || true } -# A: service defined + NOT running -> docker compose run --rm backend +# A: service defined + NOT running -> docker compose run --rm -T backend run_hook FAKE_SERVICES="backend frontend" FAKE_RUNNING="" >/dev/null -grep -q "run --rm backend" "$DOCKER_LOG" \ - || { echo "FAIL A: expected 'run --rm backend'; log: $(cat "$DOCKER_LOG" 2>/dev/null)"; exit 1; } +grep -q "run --rm -T backend" "$DOCKER_LOG" \ + || { echo "FAIL A: expected 'run --rm -T backend'; log: $(cat "$DOCKER_LOG" 2>/dev/null)"; exit 1; } # B: service running -> docker compose exec -T backend run_hook FAKE_SERVICES="backend frontend" FAKE_RUNNING="backend" >/dev/null