# Pre Push

> Mandatory pre-push security and quality pipeline. TRIGGER automatically whenever the user requests any git push: 'push my changes', 'push to origin', 'push this', 'push the code', 'commit and push', 'ship it', 'deploy to remote', 'deploy to prod/staging/production', or any git push command. Blocks hardcoded credentials + prompt-injection/exfiltration markers (14 patterns: AWS/GCP/Azure/LLM keys, private keys, connection strings, platform tokens, merge conflicts, embedded prompt-injection strings, non-standard package sources, Slack webhooks), supply chain risks (9-IOC), MCP tool poisoning (3 patterns), auth bypasses, and OWASP Top 10 vulnerabilities. Do NOT skip unless user says 'skip review' or 'force push'.

- Skill: `alexzio00/pre-push` (Agent Skill, multi-file: 7 files)
- Install (CLI): `npx skillmds@latest add alexzio00/pre-push`
- Raw SKILL.md: https://api.skillmd.com/api/skills/alexzio00/pre-push/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- License: MIT
- Author: AlexZio00 (https://skillmd.com/u/alexzio00)
- Updated: 2026-09-10
- Page: https://skillmd.com/skills/alexzio00/pre-push

---


<!--
  v3.10.0 (2026-09-06) — fixed 3 external-audit findings. (1) Step 1 scanned only
                        `git diff --staged`, but `git push` sends every commit from
                        upstream (or the merge-base with the remote's default branch)
                        to HEAD — commits already made earlier in a session could carry
                        secrets past the staged-only net. Step 1 now resolves that
                        outgoing-commit range (with a documented degraded-mode fallback
                        when no upstream/origin-HEAD is resolvable) and scans it alongside
                        the staged diff, combined into one pass; staged-diff scanning is
                        kept as its own distinct check, not replaced. (2) Scanner-script
                        discovery was hardcoded to `find ~/.claude ...`, so a standalone
                        clone of this skill's own repo (not installed under `~/.claude` or
                        a project `.claude/`) couldn't find its bundled scan_secrets.py/.pl.
                        Replaced with a 3-candidate portable search (`~/.claude` → project
                        `.claude/` → `$PWD` tree). `${CLAUDE_PLUGIN_ROOT}` was evaluated and
                        rejected for this — verified against anthropics/claude-code#38699
                        (inconsistent path across hook vs. agent Bash contexts) and #9354
                        (doesn't expand inside markdown-embedded instructions). (3) Autonomy
                        Boundary claimed test runners "read without mutating" — inaccurate,
                        since Step 4 writes a local test-count-floor state file and `npm run
                        build` writes build artifacts. Reworded to distinguish "no git-state
                        mutation" (still true, still needs no confirmation) from "read-only"
                        (false) — labeled these side-effecting-but-non-git instead.
  v3.9.0 (2026-09-06) — fixed a real regression: 9 spots in Steps 0/3/4/5a used the
                        `cmd | tail -N` / `VAR=$?` pattern, which (with no `pipefail` in this
                        shell) captures tail's exit code, not the underlying command's — lint,
                        build, and test failures were silently swallowed and reported as pass.
                        Switched each to `${PIPESTATUS[0]}` (or, where output is already captured
                        via `$(...)`, split the exit-code capture from the truncation step so the
                        exit code is read before `tail` runs). Step 4 Python gained an opt-in
                        test-count-floor check (WARN-only, pattern credited to CopilotKit/openbot's
                        scripts/test-ci.ts): compares the current pytest passed-count against the
                        previous run's count (stored in a local, gitignored state file) and warns
                        on a sharp drop — catches a whole file silently dropping out of collection
                        (e.g. an import-time exception) that would otherwise still read as a clean
                        pass. Also corrected a stale count: `scan_secrets.py` already implements
                        14 patterns (f1-f13 + the merge-conflict marker) since the f11/f12/f13
                        ports landed in v3.6.0/v3.8.0, but the frontmatter and Step 1 body text
                        still said "12 patterns" from before those ports — `scan_secrets.pl` has
                        f11 and f12 but not f13, so it covers 13.
  v3.8.0 (2026-07-24) — dual scanner support: scripts/scan_secrets.py added alongside scan_secrets.pl
                        (Python preferred when a Python runtime is available, Perl kept as fallback —
                        both are maintained). Step 6 deterministic claim-verification sub-step (generic
                        since v3.7.0) now cites a concrete example script/invocation, still optional and
                        environment-specific. LICENSE.txt added for upstream (coinangel/claude-pre-push-skill)
                        attribution. Note: scan_secrets.py's pattern coverage has diverged from
                        scan_secrets.pl since Python-side v2.2.0/v2.3.0 evasion-hardening (see Step 1
                        parity note) — reported, not reconciled, in this release.
  v3.7.0 (2026-07-18) — Step 6: cross-bundle joint pass (catches changes fragmented across bundles that
                        look safe in isolation) + deterministic claim verification (optional, checks
                        finding citations against the actual diff) added. Step 7: three-state false-positive
                        gate for high-FP pattern classes (PII/name-like matches) added.
  v3.6.0 (2026-07-15) — scan_secrets.pl synced to v2.1.0: f11 (prompt-injection string detection) + f12
                        (non-PyPI/HTTP-git supply-chain source detection) ported. Step 0: Hook Pipeline
                        Health section added (WARN-only smoke-test check before Step 1).
  v3.5.0 (2026-07-07) — 6-IOC→9-IOC expansion: dependency confusion / missing pinned version / post-install
                        hook network calls. MCP tool poisoning 3 patterns (hidden instructions/Unicode
                        deception/parameter injection) ported.
  v3.3.0 (2026-06-12) — Step 6 Large diff deterministic bundling (coverage guaranteed by structure, not agent integrity)
  v3.2.0 (2026-05-06) — Error Recovery (Step 7) + State Sync (Step 6 parallel result merge)
  v3.1.0 (2026-04-11) — defense structure: Dominant variable, Discard if,
                         Rationalization Table (6), Invariants (4), Scope Boundary
  v3.0.0 (2026-04-10) — scanner: scripts/scan_secrets.pl (12 patterns) |
                         multi-lang tests + direct lint | parallel AI review agents
-->
# Pre-Push Pipeline

## Dominant Variable
Does the secrets scanner run without exception — a single skip permanently records credentials in git history.

## Trigger

- "push"
- "git push"
- "푸시해"

## Discard If
- User explicitly says "skip review" or "force push" → proceed directly to Emergency Override
- 0 staged files (nothing to commit)
- `*.md` / `docs/**` changes only (fast-exit at Step 2, but agent review overhead unnecessary at this condition)

## Key Assumptions 
1. **`scan_secrets.pl` script accessible** — if broken: secrets scan unavailable → push blocked.
2. **git staged files exist** — if broken: inform user and halt.
3. **Agent tools (code-reviewer, etc.) dispatchable** — if broken: replace code-reviewer with an inline abbreviated review (see Error Recovery below) rather than skipping it outright — an unconditional skip trades away review quality. Conditional agents (security/database/refactor, etc.) still skip with an explicit note.

## Autonomy Boundary

Every step through Step 6 only *inspects git state* — `git diff`, `git status`, `git branch --show-current`, `git log`, linters, and the parallel review agents never mutate the repo or its remote, so none of them need a per-command confirmation to run. **Build and test runners (Step 4) are not fully read-only**: they don't touch git state (no commits, no staged-index changes), but they do have local filesystem side effects — `npm run build` writes build artifacts to the project's configured output directory, and the Python test-count-floor check writes/updates a local, gitignored state file (`.harness/test-count-floor.json`). These are non-git, locally-reversible outputs (re-running regenerates them), so they still need no per-command confirmation, but "read-only" is the wrong label for them — treat them as side-effecting-but-non-git. `git push` is the one write action against git/remote state in this entire pipeline, and it's exactly where the gates apply: it only fires after Step 8's Overall verdict is READY TO PUSH, and a push to `main`/`master` additionally needs an explicit "yes" (Step 1 protected-branch block, Safety Layers L3). Treat "runs freely" and "needs approval" as following directly from read-vs-write-to-git-state, not from step number or perceived risk.

## Step 0: Hook Pipeline Health (Fast, WARN-only)

Before scanning the diff, confirm your PreToolUse/Stop hook pipeline itself hasn't silently regressed — a broken hook is invisible until something it should have caught gets through.

If your setup has a hook smoke-test script (a script that exercises your hooks end-to-end and reports pass/fail), run it here:

```bash
SMOKE_SCRIPT=$(find ~/.claude -maxdepth 3 -iname "hook_smoke_test*" -type f 2>/dev/null | head -1)
if [ -n "$SMOKE_SCRIPT" ]; then
  "$SMOKE_SCRIPT" full 2>&1 | tail -20
  SMOKE_EXIT=${PIPESTATUS[0]}  # not $? — that would capture tail's exit code, not the smoke test's
  [ $SMOKE_EXIT -ne 0 ] && echo "⚠️ hook smoke test FAIL — hook pipeline regression suspected, recommend investigating before Step 7 Gate Check (does not block)"
else
  echo "➖ No hook smoke-test script found — skipping Step 0 (optional check)"
fi
```

WARN-only — a smoke failure does not block push (avoids introducing a new hard gate outside design scope), but MUST be surfaced in the Step 8 report.

## Step 1: Assess & Scan

Run everything in **one bash call** — variables share the same shell session, so `$STAGED_DIFF` is reused for the secrets scan without a second `git diff` invocation.

**Scan scope — staged diff AND outgoing commits**: `git diff --staged` alone is not what `git push` actually sends. A push transmits every commit from the upstream (or the merge-base with the remote's default branch) up to `HEAD` — including commits made earlier in this session that were already committed and are therefore invisible to a staged-only scan. Step 1 scans **both**: the staged diff (about to be committed) and the outgoing-commit range (already committed, not yet on the remote), combined into one pass. Staged-diff scanning is not replaced by this — it stays a distinct check, since staged changes aren't part of any commit's history yet.

**Scanner selection**: prefer `scan_secrets.py` when a Python runtime is available, otherwise fall back to `scan_secrets.pl` — both are maintained. Python needs no extra runtime install in most environments, but this package started as a Perl-based scanner, so both implementations are kept for compatibility.

**Scanner script discovery — portable, not `~/.claude`-only**: this skill's bundled scripts may live under the global `~/.claude` install, a project-local `.claude/`, or (if this skill's own repo was cloned standalone rather than installed into either) somewhere under the current working directory. `find` tries each candidate in that order and stops at the first hit, so the common case (`~/.claude`) pays no extra cost. (`${CLAUDE_PLUGIN_ROOT}` was considered and rejected — it's known to point to inconsistent paths across hook vs. agent Bash contexts, and does not expand inside markdown-embedded instructions like this one; see anthropics/claude-code#38699 and #9354.)

```bash
STAGED_FILES=$(git diff --staged --name-only)
STAGED_DIFF=$(git diff --staged)
DIFF_LINES=$(echo "$STAGED_DIFF" | wc -l | tr -d ' ')
FILE_COUNT=$(echo "$STAGED_FILES" | grep -c . || echo 0)
CURRENT_BRANCH=$(git branch --show-current)

# Outgoing-commit range (already committed, not yet pushed). Falls back to the
# remote's default branch when no upstream is configured (new/never-pushed
# branch); if that's also unresolvable (no network, origin/HEAD never set
# locally), outgoing-commit scanning degrades to staged-only — surfaced below
# and in the Step 8 report, never silently dropped.
UPSTREAM_REF=$(git rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' 2>/dev/null)
[ -z "$UPSTREAM_REF" ] && UPSTREAM_REF=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's#^refs/remotes/##')
MERGE_BASE=""
[ -n "$UPSTREAM_REF" ] && MERGE_BASE=$(git merge-base "$UPSTREAM_REF" HEAD 2>/dev/null)
if [ -n "$MERGE_BASE" ]; then
  OUTGOING_DIFF=$(git diff "$MERGE_BASE"..HEAD)
  OUTGOING_COMMIT_COUNT=$(git rev-list --count "$MERGE_BASE"..HEAD)
  OUTGOING_MODE="${UPSTREAM_REF}..HEAD (${OUTGOING_COMMIT_COUNT} commit(s))"
else
  OUTGOING_DIFF=""
  OUTGOING_MODE="unresolvable (no upstream, no origin/HEAD) — staged-only, degraded mode"
fi
COMBINED_DIFF="$STAGED_DIFF
$OUTGOING_DIFF"

SCAN_START=$(date +%s)
# Portable script discovery: try ~/.claude, then a project-local .claude/, then
# finally the current working directory tree (covers a standalone clone of this
# skill's own repo living under neither ~/.claude nor a project .claude/).
find_bundled_script() {
  local name="$1" found=""
  found=$(find "$HOME/.claude" -name "$name" -path "*/pre-push/scripts/*" -type f 2>/dev/null | head -1)
  [ -z "$found" ] && [ -d "$PWD/.claude" ] && found=$(find "$PWD/.claude" -name "$name" -path "*/pre-push/scripts/*" -type f 2>/dev/null | head -1)
  [ -z "$found" ] && found=$(find "$PWD" -maxdepth 6 -name "$name" -path "*/pre-push/scripts/*" -type f 2>/dev/null | head -1)
  echo "$found"
}
SCAN_SCRIPT_PY=$(find_bundled_script "scan_secrets.py")
SCAN_SCRIPT_PL=$(find_bundled_script "scan_secrets.pl")
if [ -n "$SCAN_SCRIPT_PY" ] && command -v python >/dev/null 2>&1; then
  SECRETS_OUTPUT=$(echo "$COMBINED_DIFF" | python "$SCAN_SCRIPT_PY")
  SECRETS_EXIT=$?
elif [ -n "$SCAN_SCRIPT_PL" ]; then
  SECRETS_OUTPUT=$(echo "$COMBINED_DIFF" | perl "$SCAN_SCRIPT_PL")
  SECRETS_EXIT=$?
else
  echo "🚨 No scanner found (scan_secrets.py or scan_secrets.pl) — secrets scan unavailable, push blocked"
  SECRETS_EXIT=1
fi
SCAN_TIME=$(($(date +%s) - SCAN_START))
echo "Branch: $CURRENT_BRANCH | Files: $FILE_COUNT | Diff: $DIFF_LINES lines | Outgoing: $OUTGOING_MODE | Scan: ${SCAN_TIME}s"
[ $SECRETS_EXIT -ne 0 ] && echo "$SECRETS_OUTPUT"
```

The preferred scanner (`scripts/scan_secrets.py`) covers **14 patterns** across three categories:
- **Credentials** (f1–f10): AWS keys, private keys, connection-string passwords, hardcoded assignments (quoted/unquoted), platform tokens (Slack, GitHub 6 types, Stripe live), Dockerfile ENV secrets, Google/Gemini API keys, npm auth tokens, LLM provider keys (Anthropic/OpenAI/HuggingFace/Replicate/Groq), Azure Storage/SAS/connection strings.
- **Injection & exfiltration channels** (f11–f13): embedded prompt-injection strings (supply-chain prompt attack), non-standard package install sources, Slack incoming webhook URLs.
- **Code integrity** (f_merge): unresolved merge conflict markers.

**Parity note**: `scripts/scan_secrets.py` began as a straight port of the Perl scanner but has since received independent anti-evasion hardening (Unicode/homoglyph normalization, reversed-line re-scan) not back-ported to `scan_secrets.pl`. Pattern coverage has also diverged: `scan_secrets.pl` has f11 and f12 but **lacks f13** (Slack webhook detection), so it covers 13 patterns, not 14 — the two implementations are **not** guaranteed to have identical coverage. Treat `scan_secrets.py` as the more complete/current scanner where they diverge.

**Design note**: the scanner intentionally scans only **added (`+`) lines**, not removed (`-`) lines — this avoids blocking commits that are *removing* a secret. Merge conflict markers are an exception and checked on all lines. This applies identically to both diffs inside `$COMBINED_DIFF`.

**Empty check**: If `$STAGED_FILES` is empty → inform the user and stop (per Discard If, this skill doesn't engage at all in that case). Note this means outgoing-commit scanning above only ever runs as a supplement to a staged-diff review, not as a standalone "just push what's already committed" path — that scenario remains a known Discard-If gap, unchanged by this fix.

**Protected branch block**: If `$CURRENT_BRANCH` is `main` or `master` → stop and ask for an explicit "yes" before proceeding.

## Step 2: Routing & Remediation

**SECRETS_EXIT=1 → BLOCKED.** Print each finding with its specific remediation:

| Finding | Remediation |
|---------|-------------|
| Merge conflict markers | Resolve conflicts: `git status` to find files, fix markers, re-stage. |
| AWS Access Key | Replace with `process.env.AWS_ACCESS_KEY_ID`. **If real key: rotate immediately** at AWS IAM console. |
| Private key | Move to `~/.ssh/` or a secrets manager. Add path to `.gitignore`. |
| Connection string password | Use `process.env.DATABASE_URL`. Never embed credentials in URLs. |
| Platform token (GitHub/Slack/Stripe) | Revoke in provider dashboard. Re-create with minimal scopes. |
| LLM API key | Replace with `process.env.ANTHROPIC_API_KEY` (or provider equivalent). Rotate if exposed. |
| Azure credential | Replace with Managed Identity or environment variable. |
| Dockerfile ENV secret | Use `--secret` mount or ARG with external injection. Never hardcode in ENV. |
| Generic hardcoded credential | Move to `.env.local` → `process.env.YOUR_KEY`. Verify `.gitignore` covers `.env*`. |

**SECRETS_EXIT=0 AND only `*.md` / `docs/**` changed** → fast exit, push directly, skip all agents.

**Otherwise** → continue to Step 3.

## Step 3: Supply Chain & Infrastructure Check (WARN — never blocks)

Scan `$STAGED_FILES` and list findings in the final report:

- **Package manifests** (`package.json`, `yarn.lock`, `pnpm-lock.yaml`, `requirements.txt`, `Gemfile`, `go.mod`, `Cargo.toml`): list all **added** dependencies. Flag misspelled or unfamiliar names as potential typosquats.
- **Infrastructure files** (`Dockerfile`, `docker-compose*.yml`, `*.tf`, `*.yaml`/`*.yml` in `k8s/` or `infra/`, `nginx.conf`): flag any ENV, ARG, or environment sections for human review.
- **Python CVE scan** (`pip-audit`): run when `requirements.txt` or `pyproject.toml` changed and `pip-audit` is installed. WARN only — never blocks.
- **9-IOC Supply Chain + MCP Check**: for newly added dependencies and MCP configuration, check 9 indicators of compromise:
  1. **External install links** — does the package README or setup script fetch from non-registry URLs?
  2. **Obfuscated exfiltration** — base64/hex-encoded strings in post-install scripts?
  3. **Capability mismatch** — does a "utility" package request network/filesystem access beyond its stated purpose?
  4. **MCP hidden instructions** — does an MCP tool description/parameter embed text meant to steer agent behavior?
  5. **MCP Unicode deception** — direction-override characters (e.g. U+202E) or homoglyphs in a tool name/description?
  6. **MCP parameter injection** — an executable command or URL embedded in a tool parameter default/enum?
  7. **Dependency confusion** — is an internal/private package name unregistered on public PyPI/npm? An attacker can register the same name (Birsan 2021). Check registry existence when adding a new package to `requirements.txt`/`package.json`.
  8. **Missing pinned version** — warn when a new dependency has no pinned version (`requests` vs `requests==2.32.3`). Unpinned = supply-chain attack surface.
  9. **Post-install hook** — does `setup.py`/`pyproject.toml`'s `[tool.setuptools.cmdclass]` or an npm `postinstall` script make network calls or write files?
  Flag any match as `⚠️ SUPPLY_CHAIN_IOC` or `⚠️ MCP_POISONING` in the report.

```bash
CHANGED_REQS=$(echo "$STAGED_FILES" | grep -E "(requirements.*\.txt|pyproject\.toml|setup\.py)$")
if [ -n "$CHANGED_REQS" ] && command -v pip-audit >/dev/null 2>&1; then
  # capture pip-audit's own exit code before truncating — piping straight into
  # $(... | tail -20) would capture tail's exit code instead
  AUDIT_RAW=$(pip-audit --format=columns 2>&1); AUDIT_EXIT=$?
  AUDIT_OUT=$(echo "$AUDIT_RAW" | tail -20)
  [ $AUDIT_EXIT -ne 0 ] && echo "pip-audit: $AUDIT_OUT"
fi
```

> **Install**: `pip install pip-audit` (Python native, no Go binary needed — preferred over osv-scanner for Python projects)

> **Further reading**: the 9-IOC checklist above is a lightweight, offline heuristic run inline by this skill — it is not a substitute for dedicated supply-chain tooling. For broader, actively maintained coverage (SBOM generation, provenance attestation, dependency health scoring), see the [OpenSSF Scorecard](https://github.com/ossf/scorecard) project and the [OWASP Cheat Sheet Series](https://cheatsheetseries.owasp.org/).

## Step 3.5: Public-Mirror Scrub (WARN — never blocks, conditional)

A push-time backstop for any write-time reminders you run elsewhere (e.g. a
pre-commit leak scanner) — different layers, so if one is bypassed the other
still catches it (defense in depth). Useful if this repo is a curated public
mirror of a private source (internal identifiers, section numbers, or
internal-only tool paths can leak into a public-facing copy through
copy-paste).

**Trigger condition**: only runs when `git remote get-url origin` matches a
repo you've configured as a known public mirror. Private/internal repos
should skip this step entirely.

```bash
ORIGIN_URL=$(git remote get-url origin 2>/dev/null || echo "")
# Replace the pattern below with your own public-mirror repo name(s).
if echo "$ORIGIN_URL" | grep -qiE "YOUR_PUBLIC_MIRROR_REPO_NAME_HERE"; then
  # (a) org-identifier / internal-jargon term list — reuse your leak-scan
  # script's pattern list if you have one, e.g. internal §-numbered rule
  # citations, private absolute paths, internal-only repo names.
  echo "$STAGED_DIFF" | grep -nE "§[0-9]+|internal-only-path-pattern" \
    && echo "⚠️ PUBLIC_MIRROR_SCRUB: possible internal identifier/section-number string found — verify before pushing to public mirror (${ORIGIN_URL})"
  # (b) binary sweep — staged non-text assets
  BINARY_FILES=$(git diff --staged --name-only --diff-filter=A | while read -r f; do
    file --mime-encoding "$f" 2>/dev/null | grep -q "binary" && echo "$f"
  done)
  [ -n "$BINARY_FILES" ] && echo "⚠️ PUBLIC_MIRROR_SCRUB: new binary file(s) — confirm these are intentional public assets: $BINARY_FILES"
  # (c) a surface the diff won't catch — internal identifiers baked into the branch name itself
  echo "$CURRENT_BRANCH" | grep -qiE "§|internal-only-path-pattern" \
    && echo "⚠️ PUBLIC_MIRROR_SCRUB: possible internal identifier in branch name — this surface isn't visible in the diff"
fi
```

WARN-only (advisory, never blocks) — mirrors the same explicit non-blocking
decision as the write-time leak scanner it backstops. Adjust the grep
patterns to whatever your own internal-only vocabulary actually is.

## Step 4: Build & Test (Fail Fast)

Detect changed languages first, then run only the relevant test/build commands. Skip entirely for config, docs, or style-only commits.

```bash
CHANGED_PY=$(echo "$STAGED_FILES" | grep -E "\.py$")
CHANGED_JS=$(echo "$STAGED_FILES" | grep -E "\.(ts|tsx|js|jsx)$")
CHANGED_GO=$(echo "$STAGED_FILES" | grep -E "\.go$")
```

**Python** — run when `.py` files changed and a test runner is configured:
```bash
if [ -n "$CHANGED_PY" ] && ([ -f "pyproject.toml" ] || [ -f "setup.py" ] || [ -f "requirements.txt" ]); then
  TEST_START=$(date +%s)
  PYTEST_OUTPUT=$(timeout 120 pytest -q 2>&1)
  PYTEST_EXIT=$?
  echo "$PYTEST_OUTPUT" | tail -20
  TEST_TIME=$(($(date +%s) - TEST_START))

  # Test-count floor (WARN-only, pattern credited to CopilotKit/openbot's scripts/test-ci.ts) —
  # exit=0 (all passed) doesn't mean nothing broke: if a whole file silently drops out of
  # collection (e.g. an import-time exception), the suite just got smaller and still "passes".
  # Track the previous run's passed-count in a local, gitignored state file and compare —
  # warn only on a sharp drop; a first run (no state file yet) just records and passes.
  PASSED_COUNT=$(echo "$PYTEST_OUTPUT" | grep -oE "[0-9]+ passed" | tail -1 | grep -oE "^[0-9]+")
  FLOOR_FILE=".harness/test-count-floor.json"
  if [ -n "$PASSED_COUNT" ]; then
    if [ -f "$FLOOR_FILE" ]; then
      LAST_COUNT=$(grep -oE '"count": *[0-9]+' "$FLOOR_FILE" | grep -oE '[0-9]+' | tail -1)
      if [ -n "$LAST_COUNT" ] && [ "$LAST_COUNT" -gt 0 ]; then
        DROP_PCT=$(( (LAST_COUNT - PASSED_COUNT) * 100 / LAST_COUNT ))
        [ "$DROP_PCT" -ge 10 ] && echo "⚠️ TEST_COUNT_FLOOR: ${PASSED_COUNT} passed (down ${DROP_PCT}% from ${LAST_COUNT} last run) — a file may have silently dropped out of collection. Ignore if intentional deletion, otherwise check for a collection error."
      fi
    fi
    mkdir -p "$(dirname "$FLOOR_FILE")"
    printf '{"count": %s, "updated": "%s"}\n' "$PASSED_COUNT" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$FLOOR_FILE"
  fi
fi
```

**Go** — run when `.go` files changed and `go.mod` exists:
```bash
if [ -n "$CHANGED_GO" ] && [ -f "go.mod" ]; then
  TEST_START=$(date +%s)
  timeout 120 go test ./... 2>&1 | tail -20
  GO_TEST_EXIT=${PIPESTATUS[0]}  # not $? — that would capture tail's exit code, not go test's
  TEST_TIME=$(($(date +%s) - TEST_START))
fi
```

**JS/TS** — build then test when source files changed:
```bash
if [ -f "package.json" ] && [ -n "$CHANGED_JS" ]; then
  BUILD_START=$(date +%s)
  timeout 120 npm run build 2>&1 | tail -30
  BUILD_EXIT=${PIPESTATUS[0]}  # not $? — that would capture tail's exit code, not the build's
  BUILD_TIME=$(($(date +%s) - BUILD_START))
  if node -e "const p=require('./package.json');process.exit(p.scripts&&p.scripts.test?0:1)" 2>/dev/null; then
    timeout 60 npm test -- --passWithNoTests 2>&1 | tail -20
    JS_TEST_EXIT=${PIPESTATUS[0]}  # not $? — that would capture tail's exit code, not the test run's
  fi
fi
```

Any failure → stop immediately. Run `build-error-resolver` agent, then restart from Step 1.

## Step 5: Lint Gate (Direct → AI)

Two sub-steps in order. Direct lint runs first (fast, no token cost). AI layer runs after.

### 5a: Direct Lint (Blocking)

Run only for changed files of the matching language.

**Python** — `ruff` preferred, `flake8` fallback:
```bash
if [ -n "$CHANGED_PY" ]; then
  # ${PIPESTATUS[0]} — not $? — is the linter's own exit code; $? here would be tail's
  if command -v ruff >/dev/null 2>&1; then
    timeout 30 ruff check $CHANGED_PY 2>&1 | tail -20; LINT_EXIT=${PIPESTATUS[0]}
  elif command -v flake8 >/dev/null 2>&1; then
    timeout 30 flake8 $CHANGED_PY 2>&1 | tail -20; LINT_EXIT=${PIPESTATUS[0]}
  fi
fi
```

**Go** — `go vet` (always available):
```bash
if [ -n "$CHANGED_GO" ]; then
  timeout 30 go vet ./... 2>&1 | tail -20; GO_VET_EXIT=${PIPESTATUS[0]}  # not $? — tail's exit code
fi
```

**JS/TS** — `eslint` if config file present:
```bash
if [ -n "$CHANGED_JS" ] && ls .eslintrc* eslint.config* 2>/dev/null | head -1 | grep -q .; then
  timeout 30 npx eslint $CHANGED_JS 2>&1 | tail -20; ESLINT_EXIT=${PIPESTATUS[0]}  # not $? — tail's exit code
fi
```

Lint fails → **BLOCK**. Fix errors before continuing to 5b.

### 5b: code-reviewer --quick Gate (AI, Serial)

**Skip if `$DIFF_LINES` < 50** — tiny diffs have negligible type/lint risk.

Run **code-reviewer --quick** (haiku) for type errors and logical lint issues that static tools miss. FAIL → fix before continuing.

```
Review the following staged diff for type errors and lint issues only.
Do NOT read entire files unless absolutely necessary.

<diff>
[paste full output of: git diff --staged]
</diff>
```

## Step 6: Launch Review Agents in Parallel

> **Spawn all applicable agents in a SINGLE response turn** using concurrent subagent calls — never sequentially. Parallel execution cuts total wall time by the duration of the slowest agent.

**Large diff — deterministic bundling** (`$DIFF_LINES` > 500 OR files > 10):

In large changesets, agents choosing which files to read on their own leads to coverage gaps — coverage is guaranteed by **structure, not agent integrity**:

1. **Deterministic bundle split**: group `$STAGED_FILES` by top-level directory/module. Natural pairs (source+test, implementation+config) go in the same bundle. Bundle size guide: diff ≤ ~300 lines or ≤ 5 files; if larger, re-split.
2. **One reviewer per bundle**: dispatch code-reviewer once per bundle — each agent receives **only its bundle's diff** (isolated context, never send full diff). Parallel cap 5 — if 6+ bundles, batch in groups of 5.
3. **Coverage validation (deterministic)**: before dispatch, verify `union of bundle files == all of $STAGED_FILES` by count. Mismatch → add missing files to final bundle. Never dispatch without this check.
4. **Cross-bundle joint pass**: after all per-bundle reviews complete, forward the union of each bundle's finding-list summary to a single reviewer (code-reviewer) for one final pass across bundle boundaries. This catches fragmentation — a change deliberately or accidentally split across bundles can look safe in each isolated review but be unsafe once combined. Do not declare a large-diff review complete without this joint pass.
5. **Deterministic claim verification** (optional, environment-specific): save the joint-pass output (finding list with file/line-range citations — e.g. `【F:path†Lstart-Lend】`-style anchors) to a file, then run a claim-verification script against it if your setup has one — for example `python "$HOME/.claude/scripts/verify_review_claims.py" --review <finding-list-file> --repo-root .`. It should check, per finding: (a) the cited file exists in the staged diff, (b) the cited line range overlaps the diff hunk, and (c) an optional grep cross-check passes — labeling each finding `CONFIRMED` or `UNVERIFIED-CLAIM`. Keep `UNVERIFIED-CLAIM` findings open for re-review before Step 7 Gate Check — do not auto-downgrade them. This script is optional and environment-specific: if `~/.claude/scripts/verify_review_claims.py` (or your own equivalent) is not present, skip this sub-step and proceed with the cross-bundle joint pass alone; note the skip in the Step 8 report.
6. **Opt-in high-risk gap-sweep second pass**: code-reviewer's own Resource Limits describe a single-pass principle, but categories like a guard lost during a move/extract, a dataclass default evaluated once, `hash()` non-determinism, a shrunk lock scope, a side-effecting predicate, asymmetric test setup/teardown, or a flipped config default are exactly the kind of thing a first pass tends to miss structurally. **Only when `$DIFF_LINES` > 500 OR files > 10 (same trigger as the large-diff bundling above)** — dispatch one fresh code-reviewer instance dedicated solely to this miss-category checklist (not re-checking findings already surfaced), capped at 8 new findings (empty result if none — no padding). This runs against the single-pass principle, so it's opt-in and scoped to large/high-risk diffs only — don't apply it to small diffs.
7. **Conditional agents** (security/database/refactor): follow existing trigger rules, but only pass diff for bundles matching those triggers.
8. **Multi-angle parallel re-attack** (opt-in, not the default): code-reviewer's 12 questions already sweep correctness/resource-management/security/design/convention in one pass, so this doesn't belong in the default pipeline. For changes that are extremely hard to reverse (a merged DB migration, payment/funds logic) — **only when the user explicitly asks for it** — dispatch 5 parallel code-reviewer instances, each pinned to one of those five lenses, to re-attack the same diff.

Small diff (≤500 lines AND ≤10 files): use existing approach, send full diff to each agent. Bundling overhead not needed.

### Always run

| Agent | Model | Role |
|-------|-------|------|
| **code-reviewer** | sonnet | Quality, dead code, duplication, logic |

### Conditional — trigger `security-reviewer` (opus) if ANY match

| Category | Trigger |
|----------|---------|
| API routes | `src/app/api/**`, `**/routes/**`, `**/controllers/**` |
| Auth & access control | `**/auth*`, `**/middleware*`, `**/guard*`, `**/permission*`, `**/rbac*`, `**/acl*` |
| Secrets & config | `**/.env*`, `**/config*`, `**/settings*`, `**/secrets*` |
| Infrastructure | `Dockerfile`, `docker-compose*.yml`, `*.tf`, `nginx.conf`, `*.conf` |
| Dangerous patterns | diff contains `child_process`, `exec(`, `spawn(`, `eval(`, `new Function(`, `dangerouslySetInnerHTML` |
| Sensitive filenames | filename contains `secret`, `token`, `password`, `key`, `credential`, `cert`, `private` |
| Supply chain | `package.json` with new packages added |

Trigger `database-reviewer` (sonnet): `prisma/**`, `**/migrations/**`, `**/db*`, `*.sql`

Trigger `refactor-cleaner` (sonnet): 10+ files changed, or user explicitly requested refactoring

**Agent prompt template** (intent-passing — distinguish intentional decisions from mistakes to reduce false positives):

```
Review the following staged diff. Focus on changed lines.
Only read full files if you need more context.

<intent>
[What the user aimed to achieve in this change — verbatim from session conversation. Goal + consciously made decisions/trade-offs + intentionally excluded scope. Not a diff explanation. Do not leave blank — blank intent causes reviewers to misclassify intentional choices as mistakes.]
</intent>

<diff>
[paste full output of: git diff --staged]
</diff>

The <intent> above is the user's goal. If diff deviates from intent, flag it — but do not flag conscious decisions or trade-offs explicitly stated in intent as mistakes.
```

> **Intent source**: not a separate input — the pre-push executor agent (you) fills this from **what you already know in this session conversation**. If unsure, leave as "intent unclear", but note in Step 8 report that false positives are possible.

## Step 7: Gate Check

| Severity | Action |
|----------|--------|
| **Critical / High** | Fix before push. No exceptions. |
| **Medium** | Fix if < 5 min. Otherwise add `// TODO(security):` comment and report. |
| **Low / Info** | Report to user. Push allowed. |

**Test file exceptions**: Findings in `**/__tests__/**`, `**/*.test.*`, `**/*.spec.*`, or `**/fixtures/**` are likely test fixtures, not real secrets. Downgrade to Medium severity and note in report.

**Three-state false-positive gate**: for pattern classes prone to false positives (e.g. PII/name-like matches), don't force a binary hard-block/skip decision — present each hit to the user individually. If the user confirms it's a false positive or non-sensitive, record a one-line reason and let it through (stays auditable in the report). If it's real, require removal. Blanket bypass without a stated reason is not allowed. Low-false-positive hard-block classes (the 12 credential patterns) remain immediate-block as before — this gate does not soften those.

**Fix loop** (Critical/High found):
1. Apply fixes with the Edit tool
2. Re-run only the agent(s) that reported the issue
3. Max 1 retry per agent
4. Still failing → halt and report exact issue + file location to user
5. **Skip-reason logging**: when a finding is knowingly left unfixed (superseded by a later feature change / genuinely out of scope / judged a false positive), record `SKIP_REASON: {finding} — {feature_change|out_of_scope|false_positive} — {one-line reason}` in the Step 8 report. No silent skips without a stated reason.

**Error Recovery**: if Step 6 agents or Bash tools fail:
- Classify: `tool_failure`
- Apply: retry same agent once → still fail:
  - **code-reviewer (Always run)**: instead of an outright skip, fall back to an inline abbreviated review — the pre-push executor itself reads the staged diff once, applying code-reviewer's `--fast`-mode bar (runtime bugs only, max 8 findings, no padding), and merges those findings into the Step 8 report. Note `⚠️ INLINE_FALLBACK: code-reviewer subagent unavailable — replaced with inline abbreviated review`.
  - **Conditional agents (security/database/refactor)**: as before, report `⚠️ TOOL_FAILURE: {agent} — manual review needed` and continue remaining steps. No silent failures.
- Step 8 report that line: explicitly note `⚠️ SKIPPED (tool_failure — {agent})` or `⚠️ INLINE_FALLBACK (code-reviewer)`.

**Parallel Conflict Resolution**: if Step 6 agents report conflicting verdicts on the same file:
- security-reviewer Critical + code-reviewer Non-critical → **Critical takes priority** (weakest link principle).
- Multiple agents, same file, both Critical → sum-once (no double-counting).
- Complete conflict (one PASS, other Critical FAIL) → **escalate to user**. No arbitrary consensus.

## Step 8: Report & Push

Include elapsed time next to each step result.

```
## Pre-Push Review Summary
Branch: <current> → origin
Files: N | Diff: N lines | Total time: Xs

- Secrets scan:      ✅ CLEAN (Xs) / 🚨 CRITICAL (N findings — push BLOCKED)
- Supply chain:      ✅ No new deps / ⚠️ N new packages (listed below)
- Build:             ✅ PASS (Xs) / ❌ FAIL (Xs) / ➖ SKIPPED (no source changes)
- Tests:             ✅ PASS (Xs) / ⚠️ SKIPPED / ❌ FAIL (N failed)
- Lint (direct):     ✅ PASS (Xs) / ❌ FAIL (N errors) / ➖ SKIPPED (no linter found)
- code-reviewer --quick:   ✅ PASS (Xs) / ❌ FAIL (N issues) / ➖ SKIPPED (<50 lines)
- code-reviewer:     ✅ PASS / ⚠️ N issues (X fixed, Y remaining)
- security-reviewer: ✅ PASS / ❌ N issues / ➖ NOT TRIGGERED
- database-reviewer: ✅ PASS / ⚠️ N issues / ➖ NOT TRIGGERED
- refactor-cleaner:  ✅ PASS / ⚠️ N suggestions / ➖ NOT TRIGGERED

[Supply chain — new packages listed here if applicable]
[Secrets remediation steps listed here if blocked]

Overall: ✅ READY TO PUSH / ❌ BLOCKED — <reason>
```

Execute `git push` only when Overall = **READY TO PUSH**.

### Memory Sync Reminder (only when READY TO PUSH)

If push target files include memory path changes:

```
💡 Memory Sync: Code has changed.
   If you haven't run session-checkpoint yet, memory may be stale.
   Consider running /session-checkpoint before ending this session.
```

## Emergency Override

If user explicitly says "skip review" or "force push":
1. Print: `⚠️ Pre-push pipeline bypassed by user request. Secrets scan and agent reviews were NOT run.`
2. Execute `git push` immediately.

---

## Safety Layers 

| Risky Action | Reversibility | Applied Layers |
|-------------|:-------------:|----------------|
| `git push` (regular branch) | low | L1+L2+L3 |
| `git push` (main/master) | low | L1+L2+L3+L4 |
| `git push --force` | low | L1+L2 (deny recommended) |
| Secrets exposure (secrets scan FAIL) | **none** | L1+L2+L3+L4 (BLOCK) |

- **L1 (Invariants)**: secrets scan must pass, Critical/High issues must be fixed.
- **L2 (Tool Restriction)**: recommend deny `git push --force`, `--no-verify` in `settings.json` hooks.
- **L3 (User Approval)**: main/master branches require explicit "yes" confirmation. Emergency Override only with "skip review"/"force push" explicit.
- **L4 (Independent Verification)**: Step 6 review agents (code-reviewer/security-reviewer/etc) provide independent verification separate from implementation.

**Session approval validity**: Emergency Override is **valid for that single push only**. No "continue skipping" — re-approval required for each push.

## Truthful Reporting

After pipeline execution:
1. **no mock deception**: Report actual results per step. Unexecuted steps marked `➖ SKIPPED`, reason noted.
2. **no test façade**: Don't trust test results blindly. If `pytest --passWithNoTests` passes, verify actual tests exist.
3. **no silent brokenness**: Final state is either `✅ READY TO PUSH` or `❌ BLOCKED`. If `⚠️ PARTIAL`, state reason explicitly.

---

## Rationalization Table

| Rationalization | Rebuttal |
|--------|------|
| "diff is small, no need to scan" | A 1-line diff can contain a single API key |
| "already passed tests locally" | Local pass ≠ staged diff safe. Other files may be mixed into staging |
| "only changed docs, so it's fine" | If SECRETS_EXIT=0 + docs-only, Step 2 auto fast-exits. Do not manually skip |
| "secrets are okay because private repo" | Private repo offers no protection. All team members have access, git history is permanent |
| "security reviewer trigger doesn't match, so I'll skip it manually" | If trigger not met, auto-skips as NOT TRIGGERED. Manual skip is a separate concern |
| "urgent bug fix, okay to skip" | Urgency increases mistake likelihood. Secrets scan < 10 seconds |

---

## Error Recovery 

On failure: **Stop → Classify → Apply Recovery → Report & Resume**.

| Failure Type | Detection Co

…(truncated)
