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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <svc> <cmd>` if the service is up, else `docker compose run --rm <svc> <cmd>` (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.
Expand Down
9 changes: 6 additions & 3 deletions configure.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <svc> ...`.".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

Expand Down
71 changes: 59 additions & 12 deletions templates/git-workflow/hooks/stop-run-checks.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
# <service> <command>` if it's up, else `docker compose run --rm <service>
# <command>`; 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}}"
Expand All @@ -71,24 +78,64 @@ 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"
}

# 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%%|*}"
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 ---
[ -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
# -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 -T ${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
Expand Down
2 changes: 2 additions & 0 deletions test/cc-manifest/test-stack-reality-preflight.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
92 changes: 92 additions & 0 deletions test/stop-run-checks/test-container-checks.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
#!/usr/bin/env bash
# F3: a CHECKS entry with a 3rd field (compose service) runs the check inside
# that service — `docker compose exec -T <svc>` when it's running, else
# `docker compose run --rm <svc>` — 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"
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 2>/dev/null || true
}

# 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 -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
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" <<EOF
#!/usr/bin/env bash
touch "$tmp/host-ran"
exit 0
EOF
chmod +x "$fakebin/hostonly"
run_hook FAKE_SERVICES="backend frontend" FAKE_RUNNING="backend" >/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"