#!/usr/bin/env bash
# Block a commit that would introduce a secret.
#
# Why this exists: on 2026-07-25 the Voyage API key was distributed to every
# execution environment (docs/inference-services.md). Four concurrent lane
# agents write scripts, logs and docs against that key. A manual habit is not a
# control; this is (CLAUDE.md: "a verification that cannot STOP the action it
# guards is decoration").
#
# Scope: STAGED CHANGES ONLY, so it is fast enough to run on every commit.
# History is a separate, occasional scan — `scripts/install-hooks.sh --scan-history`.
#
# Installed by `scripts/install-hooks.sh`, which sets core.hooksPath. Because
# git worktrees SHARE the repo config, this protects LANE commits too — which is
# the point, since lanes are the agents most likely to paste a credential into a
# debug script.
#
# Bypass, deliberately awkward and deliberately documented: `git commit
# --no-verify`. If you need it, the finding was a false positive and belongs in
# .gitleaks.toml's allowlist with a reason, not in a habit of passing the flag.
set -uo pipefail

# ---------------------------------------------------------------------------
# SHADOWED REMOTE-TRACKING REF TRIPWIRE (2026-08-18)
#
# A LOCAL branch literally named `origin/main` (refs/heads/origin/main) WINS
# git's revision lookup over the remote-tracking ref of the same bare name
# (gitrevisions(7) checks refs/heads/ before refs/remotes/). Every script that
# passes a bare `origin/<branch>` as a REVISION — rev-parse, rev-list ranges,
# merge-base, diff, log ranges, `rebase <upstream>`, `worktree add <commit-ish>`
# — is then silently redirected at the shadow. On 2026-08-18 that emptied the
# range a held merge is computed on and PUBLISHED a red-verdict merge
# (docs/2026-08-18-bare-remote-tracking-revision-sweep.md).
#
# WHY IT LIVES HERE, and what it does NOT do. NO git hook fires on branch
# CREATION — `git branch origin/main` and `git update-ref` touch nothing
# hook-visible, and the only hook that sees a bare ref write
# (reference-transaction) fires on every fetch and is not installed. So this
# is a NEXT-TOUCH TRIPWIRE, not a refusal at creation: the shadow can exist
# between creation and the next commit. That window is bounded — this repo
# commits constantly — and the callers were fixed to name the full refname in
# the same sweep, so this is the SECOND line, not the first.
#
# It runs FIRST, before gitleaks: it is the cheapest check in the file, it is a
# REPO-STATE defect rather than a content one, and worktrees SHARE the ref
# store, so one shadow trips the next commit in EVERY worktree.
#
# Every configured remote is checked, not just `origin`. A remote whose name
# prefixes existing local branches would genuinely make those branches
# ambiguous revisions — refusing is the correct answer there too, not a false
# positive.
#
# AND `origin` UNCONDITIONALLY, even when no remote by that name is configured.
# That is not belt-and-braces, it is the WORST regime: with the remote-tracking
# ref absent, bare `origin/main` is UNAMBIGUOUS, so git resolves it to the
# shadow silently — where an ambiguous name at least makes `worktree add`
# refuse. Scanning only `git remote` would skip exactly the case with no loud
# arm anywhere. Every script in this repo spells the remote `origin`.
SHADOW_HITS=""
for _remote in $(printf '%s\norigin\n' "$(git remote 2>/dev/null)" | sort -u); do
  [ -n "$_remote" ] || continue
  _hits="$(git for-each-ref --format='%(refname)' "refs/heads/$_remote/*" 2>/dev/null)"
  [ -n "$_hits" ] && SHADOW_HITS="${SHADOW_HITS}${_hits}"$'\n'
done
if [ -n "${SHADOW_HITS//[[:space:]]/}" ]; then
  {
    echo
    echo "pre-commit: BLOCKED — a LOCAL branch shadows a remote-tracking ref."
    echo
    printf '%s' "$SHADOW_HITS" | sed '/^$/d; s/^/    /'
    cat <<'MSG'

  A branch under refs/heads/<remote>/... wins git's revision lookup over
  refs/remotes/<remote>/..., so every bare `<remote>/<branch>` revision in
  this repo's scripts silently resolves to YOUR branch instead of the
  remote's tip. That has already published a held merge once.

    delete it:  git branch -D <the name printed above>
    background: docs/2026-08-18-bare-remote-tracking-revision-sweep.md

  If you truly meant to create it, you did not: rename it to something that
  is not a remote name.
MSG
  } >&2
  exit 1
fi

GITLEAKS="${GITLEAKS_BIN:-}"
if [ -z "$GITLEAKS" ]; then
  GITLEAKS=$(command -v gitleaks 2>/dev/null || true)
fi
if [ -z "$GITLEAKS" ] && [ -x "$HOME/.local/bin/gitleaks" ]; then
  GITLEAKS="$HOME/.local/bin/gitleaks"
fi

if [ -z "$GITLEAKS" ] || [ ! -x "$GITLEAKS" ]; then
  # FAIL CLOSED. An absent scanner must not read as "nothing found" — that is
  # the silent-guard failure mode this hook exists to prevent.
  cat >&2 <<'MSG'
pre-commit: gitleaks NOT FOUND — refusing the commit.

  This hook fails CLOSED: a missing scanner is not a clean scan.

  Install it:   bash scripts/install-hooks.sh
  Or point at it: GITLEAKS_BIN=/path/to/gitleaks git commit …
  Or bypass once (and know that you did): git commit --no-verify
MSG
  exit 1
fi

# Scan the STAGED DIFF — exactly what this commit would introduce.
#
# `gitleaks protect --staged` and `gitleaks git --staged` both report "0 commits
# scanned / no leaks found" on gitleaks 8.30: `protect` is a legacy alias and the
# `git` scanner walks COMMITS, not the index. Only piping the staged diff into
# `stdin` actually inspects what is about to be committed. Verified 2026-07-25 by
# planting a secret and watching all three forms pass it (see
# scripts/install-hooks.sh's self-test, which is the regression guard for this).
#
# PIPESTATUS, not $?: the exit status that matters is gitleaks', not git's.
git diff --staged | "$GITLEAKS" stdin --redact --no-banner --verbose
if [ "${PIPESTATUS[1]}" -ne 0 ]; then
  cat >&2 <<'MSG'

pre-commit: BLOCKED — gitleaks found a secret in the staged changes.

  The finding above is REDACTED; the value is still in your index.

  If it is a real secret:
    1. remove it from the file (use a path like ~/.config/<svc>/token instead)
    2. `git reset` the file, fix it, re-stage
    3. ROTATE the credential — it existed on disk and possibly in a log
  If it is a false positive:
    add an allowlist entry WITH A REASON to .gitleaks.toml
MSG
  exit 1
fi

# ---------------------------------------------------------------------------
# HOOK AUTHORING RULE: A HOOK IS A GIT SUBPROCESS, AND ITS ENVIRONMENT IS
# GIT'S (doc-index-check-not-in-precommit F2, 2026-09-05/06). Read this
# before writing any leg below that materializes an extract (a `mktemp -d`,
# an archived staged tree, a throwaway checkout) and runs `git` against it.
#
# `git commit` (and `git merge`) EXPORT their own repo binding to every hook
# they run. In a PLAIN checkout that is nothing — no `GIT_DIR`, a relative
# `.git/index`. In a LINKED WORKTREE it is ABSOLUTE and it is set:
# `GIT_DIR=<repo>/.git/worktrees/<name>`,
# `GIT_INDEX_FILE=<repo>/.git/worktrees/<name>/index`. MEASURED, not assumed,
# from a hook that dumped its own environment under both shapes — see the
# doc-index leg's own finding, F2, for the transcript.
#
# `-C <dir>` does NOT undo this. `GIT_DIR` set with no work-tree setting
# means "cwd IS the work tree", so `git -C "$TMP" init` creates NO repository
# at `$TMP` and `git -C "$TMP" add -A` stages `$TMP`'s CONTENT into the REAL
# worktree's index — silently, with rc=0. Every lane commits from a linked
# worktree, so this is not an edge case; it is the shape every commit in this
# repo takes. EVERY LANE COMMITS FROM A WORKTREE.
#
# THE RULE: any leg that runs `git` against a path OTHER than the repo the
# hook is firing in clears the repo-binding env vars FIRST, from a list
# DERIVED at run time (`git rev-parse --local-env-vars`), never a list
# copied or hand-maintained in the hook — a list git extends and the file
# does not is a silent fork. Guard the derived list's shape (empty means the
# clear cannot be trusted; skip rather than risk the real index). The calls
# that are meant to read the REAL repo (`git diff --staged`, `git
# ls-files --cached`, `git write-tree`, `git archive`, the `MERGE_HEAD`
# probe) deliberately KEEP the binding. See the DOC-INDEX DRIFT leg below and
# `.githooks/post-merge`'s DOC-INDEX DRIFT leg for the two worked
# implementations (`git rev-parse --local-env-vars` piped into an `env -u …`
# prefix on every extract-directed call).
#
# ONE NAMED LIMIT: clearing `--local-env-vars` does not clear every `GIT_*`
# variable a hook inherits — `GIT_CEILING_DIRECTORIES` is one that survives
# it, measured. Harmless only as long as nothing else in the environment
# binds a repository at the extract path; the clear is scoped, not global.
#
# AND: a hook self-test written only against a PLAIN-checkout fixture cannot
# see this class at all — the missing `GIT_DIR` there makes the broken form
# work by accident. Every self-test for a leg that runs `git` against an
# extract carries a LINKED WORKTREE group: `git worktree add` a second
# checkout, fire the hook from IT, and assert the MAIN tree's tracked set and
# index are byte-identical before and after — proven with a positive control
# (a fixture hook that skips the env clear) showing the comparator can in
# fact detect the mutation, not just that it stayed silent.
# ---------------------------------------------------------------------------

# ---------------------------------------------------------------------------
# DECISION CITATION GATE (2026-08-01) — a ruling in an authority file cites a
# decision ID. Scoped to CLAUDE.md / DESIGN.md / {{DECIDER_SLUG}}-to-do.md / docs/roles/*
#. Rationale, phrase list, escape hatch, backlog:
# docs/decision-citation-gate.md.
#
# THE HOOK IS A CONVENIENCE; `make decision-gate` IS THE AUTHORITY. This runs
# here so a violation cannot land in the first place, and the fleet watchdog
# (checks.fleet.d/50-decision-citation.sh) re-scans the branch every pass so a
# bypass surfaces anyway.
#
# It checks the STAGED tree, not the worktree: `git write-tree` + `git archive`
# materialize exactly what this commit would introduce, so an unstaged fix
# cannot make a staged violation look clean (the "green check that never ran"
# class, 2026-07-27).
#
# Bypass, documented and LOGGED:
#   DECISION_GATE_BYPASS=1 [DECISION_GATE_BYPASS_REASON="…"] git commit …
# appends to $GIT_DIR/decision-gate-bypasses.log and prints a banner.
# `git commit --no-verify` still bypasses everything, including gitleaks.
DG_SCOPE=(CLAUDE.md DESIGN.md {{DECIDER_SLUG}}-to-do.md docs/roles docs/decision-registry.md docs/decision-citation-backlog.txt)
GITDIR="$(git rev-parse --git-dir)"
TOPLEVEL="$(git rev-parse --show-toplevel)"

# ---------------------------------------------------------------------------
# REPO-COUPLING GUARD (precommit-hook-not-cwd-portable, 2026-08-26).
#
# TOPLEVEL (above) is the COMMITTING repo -- correct for "what staged content
# to look at". Every leg below that shells out to a scripts/*.py checker was
# building its path as "$TOPLEVEL/scripts/...", silently assuming the
# COMMITTING repo also carries {{PROJECT_SLUG}}'s own scripts/ directory. It does not,
# for any OTHER repo that points core.hooksPath at this hooksPath (a fixture
# repo; scripts/install-hooks.sh's own self-test, which deliberately copies
# ONLY the pre-commit file into a throwaway clone to test the exact bytes
# that will run). python3 then failed with "No such file or directory" (exit
# 2), which every leg below read as "gate is RED" and BLOCKED a benign
# commit in a repo the gate has nothing to do with. Witnessed 2026-08-26:
# install-hooks.sh's own benign-commit control failed exactly this way. Real
# {{PROJECT_SLUG}} commits are unaffected -- TOPLEVEL already IS {{PROJECT_SLUG}}'s checkout for
# them, every time.
#
# HOOK_HOME is the physical location of the RUNNING hook file itself
# (one level up from where this file sits). It differs from TOPLEVEL only
# when the committing repo is not {{PROJECT_SLUG}}. A real foreign repo that points
# core.hooksPath at {{PROJECT_SLUG}}'s ABSOLUTE .githooks -- the normal installed
# shape; scripts/install-hooks.sh always writes the MAIN worktree's absolute
# path, see that script's "WHICH .githooks" comment -- still has the
# checker scripts reachable via HOOK_HOME, even though TOPLEVEL (the
# committing repo) does not.
HOOK_HOME="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." 2>/dev/null && pwd)"

# DECISION, stated per leg (CLAUDE.md's post-mortem rule): the DECISION
# CITATION / REGISTRY DRIFT / S12 GRAMMAR legs below check {{PROJECT_SLUG}}'s OWN
# governance files (the decision registry, plans/{{TRACKER_DOC}}.md's board)
# using {{PROJECT_SLUG}}'s OWN generator scripts. There is no sensible "run {{PROJECT_SLUG}}'s
# checker against a foreign repo" reading for them the way there is for
# gitleaks (repo-agnostic secret scanning -- decided to always run,
# unconditionally, exactly as before this fix) or the conflict-marker lint
# (repo-agnostic content check -- decided further down to still run there,
# via HOOK_HOME). So these three legs SKIP, loudly, one line, whenever the
# COMMITTING repo does not itself carry the script they need -- checked
# structurally (does the file exist in TOPLEVEL), never via a hardcoded path
# or an origin-URL guess, so this keeps working under a rename, a fork, or a
# differently-pathed clone. (The PARSER UNITS leg just below is unaffected:
# it archives the STAGED content of whatever was actually committed, never
# reaches into TOPLEVEL/scripts, so it is already correctly scoped to the
# committing repo.)

dg_staged="$(git diff --staged --name-only -- "${DG_SCOPE[@]}" 2>/dev/null)"
if [ -n "$dg_staged" ]; then
  if [ "${DECISION_GATE_BYPASS:-0}" = "1" ]; then
    printf '%s\t%s\t%s\t%s\n' \
      "$(date -Is)" \
      "$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo '?')" \
      "${USER:-?}" \
      "${DECISION_GATE_BYPASS_REASON:-<no reason given>}" \
      >> "$GITDIR/decision-gate-bypasses.log"
    cat >&2 <<MSG

pre-commit: DECISION CITATION GATE BYPASSED (DECISION_GATE_BYPASS=1).
  reason: ${DECISION_GATE_BYPASS_REASON:-<no reason given>}
  logged: $GITDIR/decision-gate-bypasses.log
  The fleet watchdog re-scans the branch and will report the violation anyway.

MSG
  elif ! command -v python3 >/dev/null 2>&1; then
    # FAIL CLOSED, same principle as the gitleaks arm above.
    echo "pre-commit: python3 NOT FOUND — refusing the commit (decision gate cannot run)." >&2
    exit 1
  elif [ ! -f "$TOPLEVEL/scripts/decision-citation-gate.py" ]; then
    # SKIP, not BLOCK: see the REPO-COUPLING GUARD comment above. A repo
    # that reaches here staged a file literally named CLAUDE.md/DESIGN.md/
    # etc. but is not {{PROJECT_SLUG}} itself (no scripts/decision-citation-gate.py) --
    # {{PROJECT_SLUG}}'s own decision-registry grammar has nothing to check there.
    echo "pre-commit: SKIP — decision citation gate (scripts/decision-citation-gate.py not found in $TOPLEVEL; not this {{PROJECT_SLUG}} checkout)" >&2
  else
    DG_TMP="$(mktemp -d)"
    dg_paths=()
    while IFS= read -r p; do [ -n "$p" ] && dg_paths+=("$p"); done < <(
      git ls-files --cached -- "${DG_SCOPE[@]}" 2>/dev/null)
    if [ "${#dg_paths[@]}" -gt 0 ]; then
      git archive --format=tar "$(git write-tree)" -- "${dg_paths[@]}" \
        | tar -x -C "$DG_TMP"
      dg_rc=0
      python3 "$TOPLEVEL/scripts/decision-citation-gate.py" --repo "$DG_TMP" || dg_rc=$?
      if [ "$dg_rc" -ne 0 ]; then
        cat >&2 <<MSG

pre-commit: BLOCKED — the decision citation gate is RED on the STAGED content.

  A claim that {{DECIDER}} ruled something, in an authority file, must cite a
  decision ID that resolves against docs/decision-registry.md. Prose is
  discussion; only a question-block selection ratifies.

  Fix it, reword it, or add \`<!-- decision-gate: allow <reason> -->\`.
  See docs/decision-citation-gate.md.

  Bypass (logged, and the watchdog reports it anyway):
    DECISION_GATE_BYPASS=1 DECISION_GATE_BYPASS_REASON="why" git commit …
MSG
        rm -rf "$DG_TMP"
        exit 1
      fi
    fi
    rm -rf "$DG_TMP"
  fi
fi

# Parser units: only when the GENERATOR or its suite is staged. The drift check
# below can only compare rows that already exist on disk; a parser change that
# would mis-read a FUTURE question block leaves today's registry byte-identical
# and sails through it. That is exactly what happened on 2026-08-02, when a
# rewrite adding grammar 5 dropped every grammar-3 preview-echo answer and only
# two PRE-EXISTING rows made the loss visible. So the units run here too — they
# need no corpus and no network, so unlike the drift leg they are valid on any
# box. Staged tree, same principle as the citation arm above.
pu_staged="$(git diff --staged --name-only \
  -- scripts/extract-decision-registry.py scripts/test_extract_decision_registry.py \
  2>/dev/null)"
if [ -n "$pu_staged" ]; then
  # A staged DELETION would satisfy the test above while the staged tree holds
  # no file to check — the checker would exit 2 and block a legitimate commit.
  # `ls-files --cached` is the staged tree's own answer to "does it exist".
  # Check that BOTH files are present; if either is absent, SKIP.
  if ! git ls-files --cached --error-unmatch \
    scripts/extract-decision-registry.py scripts/test_extract_decision_registry.py \
    >/dev/null 2>&1; then
    echo "pre-commit: SKIP — parser units (one or both files absent from staged tree)" >&2
  elif ! command -v python3 >/dev/null 2>&1; then
    echo "pre-commit: python3 NOT FOUND — refusing the commit (parser units cannot run)." >&2
    exit 1
  else
    PU_TMP="$(mktemp -d)"
    git archive --format=tar "$(git write-tree)" \
      -- scripts/extract-decision-registry.py scripts/test_extract_decision_registry.py \
      | tar -x -C "$PU_TMP"
    pu_rc=0
    python3 "$PU_TMP/scripts/test_extract_decision_registry.py" >&2 || pu_rc=$?
    rm -rf "$PU_TMP"
    if [ "$pu_rc" -ne 0 ]; then
      cat >&2 <<'MSG'

pre-commit: BLOCKED — the decision-registry PARSER units are RED on the staged
  content. That script GENERATES the governance record; a grammar it reads
  wrong enters the record wrong, and for a new question block there is nothing
  to diff against.

    run it:  make decision-parser-units
    suite:   scripts/test_extract_decision_registry.py

MSG
      exit 1
    fi
  fi
fi

# Registry drift: only when the registry itself is staged. It is GENERATED, and
# a hand edit is the failure mode that would make the whole scheme theatre. On a
# box with no transcript corpus the check fails CLOSED (rc=2) — which is exactly
# right: editing the registry where it cannot be verified must not be possible.
if git diff --staged --name-only -- docs/decision-registry.md 2>/dev/null | grep -q .; then
  if [ ! -f "$TOPLEVEL/scripts/extract-decision-registry.py" ]; then
    # SKIP, not BLOCK: see the REPO-COUPLING GUARD comment above.
    echo "pre-commit: SKIP — registry drift check (scripts/extract-decision-registry.py not found in $TOPLEVEL; not this {{PROJECT_SLUG}} checkout)" >&2
  elif ! python3 "$TOPLEVEL/scripts/extract-decision-registry.py" --check >&2; then
    cat >&2 <<'MSG'

pre-commit: BLOCKED — docs/decision-registry.md is not what primary record
  produces. It is GENERATED, never hand-edited.

    new decision ratified:  python3 scripts/extract-decision-registry.py --write
    otherwise:              revert your edit

MSG
    exit 1
  fi
fi

# §12 BOARD GRAMMAR (2026-08-17) — only when plans/{{TRACKER_DOC}}.md is staged.
#
# WHY HERE. Lanes hand-write §12 rows; until now the only caller of
# scripts/s12-grammar-check.py was `scripts/merge-window.sh` step 5b, which the
# COORDINATOR runs. So an authoring defect — a 4-cell row, an unescaped literal
# `|`, a status cell whose first token is prose — was found hours later, in a
# merge window, by someone who did not write it, and cost a RED/conflicted
# window to fix. Three instances on 2026-08-17 alone. This moves the same
# checker to authoring time, in the lane's own worktree, where the author can
# fix it in seconds. merge-window step 5b stays as the backstop.
#
# It checks the STAGED tree, same principle and same mechanism as the citation
# arm above: an unstaged fix must not make a staged violation look clean.
#
# rc=2 (checker IO/usage error) BLOCKS too — fail closed, like the gitleaks and
# python3 arms. No dedicated bypass: `git commit --no-verify` is the documented
# escape, and merge-window re-checks regardless.
if git diff --staged --name-only -- plans/{{TRACKER_DOC}}.md 2>/dev/null | grep -q .; then
  # A staged DELETION would satisfy the test above while the staged tree holds
  # no file to check — the checker would exit 2 and block a legitimate commit.
  # `ls-files --cached` is the staged tree's own answer to "does it exist".
  if git ls-files --cached --error-unmatch plans/{{TRACKER_DOC}}.md >/dev/null 2>&1; then
    if ! command -v python3 >/dev/null 2>&1; then
      echo "pre-commit: python3 NOT FOUND — refusing the commit (§12 grammar check cannot run)." >&2
      exit 1
    fi
    if [ ! -f "$TOPLEVEL/scripts/s12-grammar-check.py" ]; then
      # SKIP, not BLOCK: see the REPO-COUPLING GUARD comment above.
      echo "pre-commit: SKIP — §12 grammar check (scripts/s12-grammar-check.py not found in $TOPLEVEL; not this {{PROJECT_SLUG}} checkout)" >&2
    else
      S12_TMP="$(mktemp -d)"
      git archive --format=tar "$(git write-tree)" -- plans/{{TRACKER_DOC}}.md \
        | tar -x -C "$S12_TMP"
      # The checker echoes back the path it was GIVEN, which here is a temp dir
      # that is gone by the time the author reads the message. sed rewrites the
      # prefix so every `file:line:` is repo-relative and clickable; the line
      # numbers already match the real file. PIPESTATUS[0], not $? — sed's status
      # is not the verdict.
      s12_rc=0
      python3 "$TOPLEVEL/scripts/s12-grammar-check.py" \
        "$S12_TMP/plans/{{TRACKER_DOC}}.md" 2>&1 | sed "s#$S12_TMP/##g" >&2
      s12_rc="${PIPESTATUS[0]}"
      rm -rf "$S12_TMP"
      if [ "$s12_rc" -ne 0 ]; then
        cat >&2 <<'MSG'

pre-commit: BLOCKED — plans/{{TRACKER_DOC}}.md{{TRACKER_SECTION}} is RED on the STAGED content.

  A §12 row is parsed by the fleet board (ops/fleet-board/collect.sh +
  render.py) and by the merge tripwires. It must be EXACTLY 5 cells, and a
  literal `|` inside a cell must be written `\|` — an unescaped one shatters
  the row and silently shifts every later cell left.

    run it:  python3 scripts/s12-grammar-check.py
    grammar: plans/{{TRACKER_DOC}}.md{{TRACKER_SECTION}} "Board-parsed grammar"

MSG
        exit 1
      fi
    fi
  fi
fi

# FLEET-LANES TABLE GRAMMAR (2026-08-31, task fleet-lanes-table-grammar-gate)
# — only when docs/fleet-lanes.md is staged.
#
# WHY HERE. Same argument as the §12 leg just above: docs/fleet-lanes.md has
# several markdown tables and nothing gated them — an unescaped literal `|`
# silently mis-parsed the {{BUILD_NODE_WORD_UPPER}} hardware row to 6 cells (found 2026-08-17,
# {{BUILD_NODE}}-product-release-rebuild). Author-time is where the fix is seconds, not a
# later merge-window discovery; merge-window.sh step 5d stays as the backstop.
#
# It checks the STAGED tree, same principle and mechanism as the §12 leg and
# the citation arm above: an unstaged fix must not make a staged violation
# look clean.
#
# rc=2 (checker IO/usage error) BLOCKS too — fail closed, same as the §12 leg.
# No dedicated bypass: `git commit --no-verify` is the documented escape, and
# merge-window re-checks regardless.
if git diff --staged --name-only -- docs/fleet-lanes.md 2>/dev/null | grep -q .; then
  # A staged DELETION would satisfy the test above while the staged tree holds
  # no file to check — same guard as the §12 leg.
  if git ls-files --cached --error-unmatch docs/fleet-lanes.md >/dev/null 2>&1; then
    if ! command -v python3 >/dev/null 2>&1; then
      echo "pre-commit: python3 NOT FOUND — refusing the commit (fleet-lanes table grammar check cannot run)." >&2
      exit 1
    fi
    if [ ! -f "$TOPLEVEL/scripts/fleet-lanes-grammar-check.py" ]; then
      # SKIP, not BLOCK: see the REPO-COUPLING GUARD comment above.
      echo "pre-commit: SKIP — fleet-lanes table grammar check (scripts/fleet-lanes-grammar-check.py not found in $TOPLEVEL; not this {{PROJECT_SLUG}} checkout)" >&2
    else
      FLC_TMP="$(mktemp -d)"
      git archive --format=tar "$(git write-tree)" -- docs/fleet-lanes.md \
        | tar -x -C "$FLC_TMP"
      # Same rewrite-the-prefix reasoning as the §12 leg: the checker echoes
      # back the path it was GIVEN, a temp dir gone by the time the author
      # reads the message. PIPESTATUS[0], not $? — sed's status is not the
      # verdict.
      flc_rc=0
      python3 "$TOPLEVEL/scripts/fleet-lanes-grammar-check.py" \
        "$FLC_TMP/docs/fleet-lanes.md" 2>&1 | sed "s#$FLC_TMP/##g" >&2
      flc_rc="${PIPESTATUS[0]}"
      rm -rf "$FLC_TMP"
      if [ "$flc_rc" -ne 0 ]; then
        cat >&2 <<'MSG'

pre-commit: BLOCKED — docs/fleet-lanes.md table grammar is RED on the STAGED content.

  Every table's rows must parse to that table's OWN header's cell count. A
  literal `|` inside a cell must be written `\|` — an unescaped one shatters
  the row and silently shifts every later cell left (the {{BUILD_NODE_WORD_UPPER}} hardware row
  defect, fleet-lanes-table-grammar-gate).

    run it:  python3 scripts/fleet-lanes-grammar-check.py

MSG
        exit 1
      fi
    fi
  fi
fi

# ---------------------------------------------------------------------------
# CARGO LOCK COVERAGE GATE (precommit-cargo-lock-leg, 2026-09-01) — only when
# the staged diff touches a Cargo.toml or Cargo.lock in the root or {{SECOND_WORKSPACE}}
# workspace (never storage/ — inherited Neon workspace, its own Cargo.lock,
# docs/{{PROJECT_SLUG}}-hooks.md).
#
# WHY HERE. testing/lint/gate.sh's leg 0 (`cargo metadata --locked`, `make
# lock-check`) already exists (cargo-lock-resolution-gate, 2026-08-14) and
# runs unconditionally ahead of every leg gate.sh dispatches — but its ONLY
# caller in the merge pipeline was the {{BUILD_NODE_UPPER}}/{{BENCH_NODE}} referee
# (scripts/referee-payload.sh's `make fmt-check`/`make clippy`), a full
# fleet dispatch and, until this task, a fmt+clippy+test run away from the
# commit that broke it. A Cargo.toml changed with no matching Cargo.lock
# regen (precommit-cargo-lock-leg, 2026-08-28: crates/{{PRODUCT_SLUG}}-query/Cargo.toml)
# went red on the referee three times before the cause was even read — the
# gate WORKED, but the earliest the author could learn why was minutes into
# a fleet run. This leg is the SAME check, moved to author time: `cargo
# generate-lockfile`, re-stage, recommit, in seconds.
#
# WHY DISK, NOT THE STAGED TREE (an explicit exception to this file's usual
# staged-tree principle). `cargo metadata` needs every workspace member's
# manifest AND at least a target stub (src/lib.rs / src/main.rs / etc — cargo
# refuses "no targets specified" on a manifest-only tree, VERIFIED against
# {{BENCH_NODE_WORD}} 2026-09-01) present to resolve, and the root workspace has PATH
# dependencies straight into storage/ (VERIFIED the same way: excluding
# storage/ from a materialized tree breaks resolution with "Unable to update
# .../storage/libs/storage_node_api"). Materializing a working COMPILE-ABLE
# staged tree here would mean archiving most of the repository on every
# Cargo.toml-touching commit — not fast. So this leg reuses
# testing/lint/gate.sh's OWN already-shipped, already-tested contract
# (testing/lint/lock-gate-selftest.sh) against the WORKING TREE, exactly like
# `make lock-check` — gate.sh's own header already documents and accepts this
# as leg 0's "KNOWN RESIDUAL": a Cargo.lock regenerated on disk but not
# `git add`ed passes here (disk == what a clean checkout would produce) and
# is caught downstream, at the referee's clean-clone run, same as today. This
# leg is not a new gap; it is the existing one, earlier.
#
# CARGO IS NOT TREATED AS A UNIVERSAL TOOL (unlike gitleaks/python3 above,
# whose absence BLOCKS). This repo's own doctrine is "never cargo on the
# coordinator box" (CLAUDE.md build-and-test rules) — commits are made from
# lane worktrees on that same box, which by design carries no cargo
# (VERIFIED: this lane's own worktree has none). Failing closed on a
# universally-absent tool would silently convert this leg into "block every
# Cargo.toml/Cargo.lock commit, forever" on the box where nearly every commit
# in this fleet is made — worse than not having the leg. So a missing cargo
# SKIPS, loudly; the referee (testing/lint/gate.sh's leg 0, unconditional
# there) remains the backstop everywhere this leg cannot run, exactly as it
# was before this leg existed. Where this leg DOES fire — a developer's own
# cargo-equipped machine, including an external/OSS clone (see the
# REPO-COUPLING GUARD comment above) — it catches the class in seconds
# instead of a fleet round-trip.
#
# rc=2 (checker IO/usage error) BLOCKS too — fail closed, like every other
# leg in this file that DOES run. No dedicated bypass beyond
# `git commit --no-verify`; testing/lint/gate.sh's leg 0 and the referee both
# re-check regardless.
cl_staged="$(git diff --staged --name-only -- '*Cargo.toml' '*Cargo.lock' 2>/dev/null \
  | grep -v '^storage/' || true)"
if [ -n "$cl_staged" ]; then
  if [ ! -f "$TOPLEVEL/testing/lint/gate.sh" ]; then
    # SKIP, not BLOCK: see the REPO-COUPLING GUARD comment above.
    echo "pre-commit: SKIP — cargo lock coverage gate (testing/lint/gate.sh not found in $TOPLEVEL; not this {{PROJECT_SLUG}} checkout)" >&2
  elif ! command -v cargo >/dev/null 2>&1; then
    echo "pre-commit: SKIP — cargo lock coverage gate (no cargo on PATH; this box is not expected to carry one, see this leg's own comment above — testing/lint/gate.sh's leg 0 in the referee is the backstop)" >&2
  else
    CL_LOG="$(mktemp)"
    ( cd "$TOPLEVEL" && "$TOPLEVEL/testing/lint/gate.sh" lock ) > "$CL_LOG" 2>&1
    cl_rc=$?
    if [ "$cl_rc" -ne 0 ]; then
      cat "$CL_LOG" >&2
      cat >&2 <<MSG

pre-commit: BLOCKED — the cargo lock coverage gate is RED (testing/lint/gate.sh lock).
  The committed Cargo.lock does not cover the committed Cargo.toml — a clean
  checkout would RE-RESOLVE and build different dependency versions than
  this tree was tested with.

    fix:  cargo generate-lockfile   (or: cd {{SECOND_WORKSPACE}} && cargo generate-lockfile)
    then: git add Cargo.lock   (or {{SECOND_WORKSPACE}}/Cargo.lock), and recommit

  This checks the WORKING TREE, same as \`make lock-check\` — a regenerated
  but un-\`git add\`ed lock passes here and is caught at the referee's
  clean-clone run (see this leg's own comment, "WHY DISK, NOT THE STAGED
  TREE", above).
MSG
      rm -f "$CL_LOG"
      exit 1
    fi
    rm -f "$CL_LOG"
  fi
fi

# ---------------------------------------------------------------------------
# POST-CLEAR MANIFEST DRIFT (2026-08-28) — only when the staged diff touches a
# file the manifest COUNTS.
#
# WHY HERE. `make lint` runs `post-clear-manifest-check`, but nothing ran it at
# COMMIT time, so a hook-green commit pushed main with `make lint` RED twice on
# 2026-08-27 (verification-ledger, ~8:45am) and the class recurred all day:
# every direct commit touching a counted document needed a MANUAL regen that
# nobody was reminded to do. Same argument, same shape and same placement as
# the §12 grammar leg just above — move the existing checker to authoring time,
# where the author fixes it in seconds; `make lint` stays the authority and the
# backstop.
#
# SCOPE IS DERIVED, NEVER COPIED. The counted set lives in the manifest's own
# table and grows; a list of it here would drift, which is the very defect this
# check exists to close. `--list-inputs` (added in the same commit as this leg)
# is the generator's own answer to "what can change my verdict": the manifest,
# every counted table cell, and the generator script itself. The generator is
# in that list deliberately — a parser or arithmetic change flips the verdict
# with no document staged at all, exactly the reasoning behind the PARSER UNITS
# leg for the decision registry. That is a superset of "a file the manifest
# counts", and the widening is deliberate.
#
# The list is read from the WORKING TREE ($TOPLEVEL), while the CHECK runs on
# the STAGED tree. That split is intentional and it is not symmetric: the check
# must be exact (an unstaged fix must not make a staged violation look clean),
# the trigger only has to be a superset in practice. The one case it can miss
# is an UNSTAGED edit to the manifest that removes a row for a file this commit
# stages -- `make lint` catches it, and the hook is documented as a convenience
# everywhere else in this file too.
#
# rc=2 or any non-zero from the checker BLOCKS — fail closed, like every other
# leg. No dedicated bypass: `git commit --no-verify` is the documented escape.
#
# DECISION (repo-coupling guard, 2026-08-26 convention): this leg checks
# {{PROJECT_SLUG}}'s OWN boot manifest with {{PROJECT_SLUG}}'s OWN generator, exactly like the
# decision-citation / registry-drift / §12 legs, so it SKIPS loudly when the
# COMMITTING repo does not carry the script — checked structurally, never by a
# hardcoded path or an origin-URL guess.
#
# DELIBERATELY NOT IMPLEMENTED: the same leg for `doc-index --check`
# (docs/document-guide.md). It is the same drift class and the same day, and
# the check is just as fast (~0.15s, measured 2026-08-28), so the cut is about
# CORRECTNESS, not cost. Three reasons, any one of which is disqualifying:
#
#   1. IT WOULD REFUSE EVERY CONFLICTED-MERGE CONCLUSION. pre-commit fires when
#      you `git commit` to finish a conflicted merge, and MERGE_HEAD still
#      exists at that moment. `scripts/generate-doc-index.py` REFUSES in both
#      --write and --check whenever MERGE_HEAD is set (2026-08-18,
#      `doc-index-midmerge-duplicates`), deliberately with no escape hatch. Any
#      merge touching a `.md` would be unfinishable. The manifest leg has no
#      such problem: it never calls `git ls-files`, and `git write-tree` works
#      once the resolutions are staged.
#   2. IT CANNOT CHECK THE STAGED TREE. Its input set is discovered with `git
#      ls-files`, so it needs a real checkout; the materialized staged tree is
#      not one. It would have to grade the WORKING tree — a weaker contract
#      than every other leg here, and one an unstaged fix can launder.
#   3. ITS INPUT SET IS EVERY TRACKED `*.md`, so it is not fast-SCOPED at all:
#      it would run on nearly every commit this repo makes, and it was RED on
#      origin/main when this leg was written (`docs/2026-08-27-count-at-depth-
#      cost.md` declares `class: finding`, which is not in the closed
#      vocabulary), so landing it would have blocked essentially every commit
#      in the repo. Escalated to the coordinator rather than fixed here — the
#      right frontmatter class is someone else's call, and a red `make lint`
#      on main crosses this task's boundary.
PCM_GEN="$TOPLEVEL/scripts/generate-post-clear-manifest.py"
if [ ! -f "$PCM_GEN" ]; then
  echo "pre-commit: SKIP — post-clear manifest check (scripts/generate-post-clear-manifest.py not found in $TOPLEVEL; not this {{PROJECT_SLUG}} checkout)" >&2
elif ! command -v python3 >/dev/null 2>&1; then
  # FAIL CLOSED, same principle as the gitleaks and §12 arms.
  echo "pre-commit: python3 NOT FOUND — refusing the commit (post-clear manifest check cannot run)." >&2
  exit 1
else
  # rc on its own line, never `if ! cmd` — that records 0 for every failure
  # (CLAUDE.md, shell-boundary rule). An unreadable manifest means the SCOPE is
  # unknown, which is not the same as "out of scope": block rather than skip.
  pcm_inputs="$(python3 "$PCM_GEN" --list-inputs --root "$TOPLEVEL" 2>&1)"
  pcm_lrc=$?
  if [ "$pcm_lrc" -ne 0 ]; then
    {
      echo
      echo "pre-commit: BLOCKED — could not determine the post-clear manifest's input set (rc=$pcm_lrc)."
      printf '%s\n' "$pcm_inputs"
      echo
      echo "  The scope of this check is UNKNOWN, which is not the same as out of scope."
      echo "  If docs/POST-CLEAR.md is being removed on purpose: git commit --no-verify"
    } >&2
    exit 1
  fi
  pcm_list=()
  while IFS= read -r p; do [ -n "$p" ] && pcm_list+=("$p"); done <<< "$pcm_inputs"
  # SHAPE GUARD on the comparator's own input (CLAUDE.md): an empty or
  # implausibly short input list would make the intersection below vacuously
  # empty and this leg would SKIP EVERY COMMIT while looking healthy. The list
  # always holds at least the manifest and the generator itself.
  if [ "${#pcm_list[@]}" -lt 2 ]; then
    echo "pre-commit: BLOCKED — post-clear manifest --list-inputs returned ${#pcm_list[@]} path(s); expected at least 2 (the manifest and the generator). Refusing rather than skipping silently." >&2
    exit 1
  fi
  declare -A pcm_want=()
  for p in "${pcm_list[@]}"; do pcm_want["$p"]=1; done
  # -z framing: `--name-only` QUOTES a path with non-ASCII or special
  # characters, and a quoted name would silently fail to match a real input.
  pcm_hit=""
  while IFS= read -r -d '' p; do
    if [ -n "${pcm_want[$p]:-}" ]; then pcm_hit="$p"; break; fi
  done < <(git diff --staged --name-only -z 2>/dev/null)

  if [ -n "$pcm_hit" ]; then
    PCM_TMP="$(mktemp -d)"
    # Archive paths come from `ls-files --cached` INTERSECTED with the input
    # list, so `git archive` is never handed a pathspec the staged tree lacks
    # (which is fatal, and would read as a checker failure). A counted path
    # that is genuinely absent is then absent from the extract too, and the
    # generator reports it as "table cell(s) reference file(s) that do not
    # exist" — the loud, correct outcome, in the checker's own words.
    pcm_paths=()
    while IFS= read -r -d '' p; do [ -n "$p" ] && pcm_paths+=("$p"); done < <(
      git ls-files --cached -z -- "${pcm_list[@]}" 2>/dev/null)
    if [ "${#pcm_paths[@]}" -eq 0 ]; then
      echo "pre-commit: SKIP — post-clear manifest check (none of its inputs are in the staged tree)" >&2
    else
      git archive --format=tar "$(git write-tree)" -- "${pcm_paths[@]}" \
        | tar -x -C "$PCM_TMP"
      pcm_arc=$?
      if [ "$pcm_arc" -ne 0 ]; then
        echo "pre-commit: BLOCKED — could not materialize the staged tree for the post-clear manifest check (archive/extract rc=$pcm_arc)." >&2
        rm -rf "$PCM_TMP"
        exit 1
      fi
      # RUN THE STAGED CHECKER against the staged tree, when this commit stages
      # one. The generator's parsing and arithmetic ARE the definition of
      # correct, so a commit that changes it must be graded by the version it
      # introduces, not by the one already on disk — the same reasoning as the
      # PARSER UNITS leg above. It is already in the extract (it is one of its
      # own `--list-inputs`), so this costs nothing; TOPLEVEL's copy is the
      # fallback for a commit that does not stage it.
      PCM_RUN="$PCM_TMP/$(basename "$(dirname "$PCM_GEN")")/$(basename "$PCM_GEN")"
      [ -f "$PCM_RUN" ] || PCM_RUN="$PCM_GEN"
      # sed rewrites the temp-dir prefix out of the checker's messages: the
      # author reads them after the directory is gone. PIPESTATUS[0], not $? —
      # sed's status is not the verdict.
      pcm_rc=0
      python3 "$PCM_RUN" --check --root "$PCM_TMP" 2>&1 | sed "s#$PCM_TMP/##g" >&2
      pcm_rc="${PIPESTATUS[0]}"
      rm -rf "$PCM_TMP"
      if [ "$pcm_rc" -ne 0 ]; then
        cat >&2 <<'MSG'

pre-commit: BLOCKED — docs/POST-CLEAR.md is RED on the STAGED content (the
  boot-manifest word counts have drifted from the files they measure).

  The counts are MEASURED, not estimated, and they are what a post-/clear
  session budgets its boot on. `make lint` runs this same check, so a commit
  that lands stale counts turns main RED for everyone.

    run it:  python3 scripts/generate-post-clear-manifest.py --write
    or:      make post-clear-manifest
    then re-stage docs/POST-CLEAR.md and commit again.

MSG
        exit 1
      fi
    fi
    rm -rf "$PCM_TMP" 2>/dev/null
  fi
fi

# ---------------------------------------------------------------------------
# DOC-INDEX DRIFT (2026-09-05) — only when the staged diff can change the
# generated document map.
#
# THIS LEG IS WHERE THE HOOK AUTHORING RULE ABOVE ("A HOOK IS A GIT
# SUBPROCESS...") WAS LEARNED — F2 of doc-index-check-not-in-precommit. Read
# that block first; the env-clearing calls below are its worked example.
#
# WHY HERE. `docs/document-guide.md` is GENERATED from per-document frontmatter
# and proven byte-identical by `make lint`'s `doc-index-check`, but nothing ran
# that check at COMMIT time. A stale guide therefore reached the default branch
# and was found only later, by the merge window or by the next `make lint` —
# four merges in one evening on 2026-09-05 each left it stale and it was
# regenerated by hand afterwards. Same drift class, same argument, same shape
# and the same placement as the post-clear manifest leg above: move the existing
# checker to authoring time, where the author fixes it in seconds, and leave
# `make lint` as the authority and the backstop.
#
# THIS LEG REPLACES A "DELIBERATELY NOT IMPLEMENTED" NOTE that stood in the
# comment above from 2026-08-28. Each of its three reasons is disposed of here
# rather than dropped:
#
#   1. "IT WOULD REFUSE EVERY CONFLICTED-MERGE CONCLUSION." Still the governing
#      constraint, and it is why this leg WARNS instead of blocking whenever
#      MERGE_HEAD is set. The reason is NOT that the verdict is untrustworthy
#      there — see reason 2, the staged tree grades fine mid-merge — it is that
#      THE REMEDY IS UNAVAILABLE: `scripts/generate-doc-index.py --write`
#      refuses while MERGE_HEAD is set, deliberately and with no escape hatch,
#      so an author blocked here could not regenerate to satisfy the block and
#      the only way out would be `--no-verify`. A gate that can only be
#      satisfied by bypassing it is not a gate. So mid-merge the check still
#      RUNS and still reports, as a WARNING that names the regeneration to run
#      once the merge commit exists — which is the state a successor actually
#      reads, and where `make lint` runs it anyway.
#      MERGE_HEAD IS ASKED OF GIT, never stat-ed as `.git/MERGE_HEAD`: in a
#      linked worktree `.git` is a FILE and MERGE_HEAD lives in the per-worktree
#      gitdir, so the naive path test silently never fires (the generator's own
#      `merge_in_progress` carries that note). Every lane commits from a
#      worktree, so the path test would have been a leg that never warned.
#   2. "IT CANNOT CHECK THE STAGED TREE — its input set is discovered with `git
#      ls-files`, so it needs a real checkout; the materialized staged tree is
#      not one." CLOSED, by making the materialized tree a real checkout: the
#      staged `*.md` set plus the generator are extracted from `git write-tree`
#      exactly as the post-clear manifest leg above does it, and then `git init`
#      + `git add -A` inside that temp directory gives the generator the index
#      its `git ls-files` reads. MEASURED on the full estate 2026-09-05: the
#      staged-tree run and the working-tree run print the same line, `533
#      documents classified, 382 excluded, index byte-identical`. So this leg
#      grades the STAGED tree exactly like every other leg here, and an unstaged
#      fix cannot launder a staged violation.
#   3. "IT WAS RED ON THE DEFAULT BRANCH." It is not any more: measured
#      2026-09-05 on a 916-path estate, `--check` alone runs in ~0.2s and the
#      WHOLE leg — extract, `git init`, `git add -A`, check — in ~0.9s. The
#      trigger is still scoped to staged `*.md` names plus the generator itself:
#      partly that cost, and mostly so a code-only commit cannot be stopped by
#      an unrelated red sitting in someone's working tree.
#
# A non-zero rc from the checker BLOCKS — fail closed, like every other leg. No
# dedicated bypass: `git commit --no-verify` is the documented escape.
#
# WHAT THIS LEG DOES NOT CATCH, stated plainly. A clean `git merge` never runs
# pre-commit at all, so the merge-induced drift that motivated the leg is only
# half closed by it: a CONFLICTED merge conclusion gets the warning above, an
# automatic one gets nothing. This leg closes the AUTHORING-TIME half in full;
# the rest stays with `make lint` and the merge window, and a `post-merge`
# warning would be a separate change to a separate hook.
#
# DECISION (repo-coupling guard, 2026-08-26 convention): this leg checks the
# COMMITTING repo's OWN document map with that repo's OWN generator, exactly
# like the decision-citation / registry-drift / post-clear-manifest legs, so it
# SKIPS loudly when the committing repo does not carry the script — checked
# structurally, never by a hardcoded path or an origin-URL guess.
DGI_GEN="$TOPLEVEL/scripts/generate-doc-index.py"
if [ ! -f "$DGI_GEN" ]; then
  echo "pre-commit: SKIP — doc-index check (scripts/generate-doc-index.py not found in $TOPLEVEL; not this {{PROJECT_SLUG}} checkout)" >&2
elif ! command -v python3 >/dev/null 2>&1; then
  # FAIL CLOSED, same principle as the gitleaks and post-clear-manifest arms.
  echo "pre-commit: python3 NOT FOUND — refusing the commit (doc-index check cannot run)." >&2
  exit 1
else
  # TRIGGER. Any staged `*.md` — every tracked one is an input, whether added,
  # removed, renamed, reclassified or given a new purpose — or the generator
  # itself, since a parser or render change flips the verdict with no document
  # staged at all (the same reasoning as the post-clear manifest leg's
  # `--list-inputs`, and as the decision registry's PARSER UNITS leg).
  #
  # -z framing: `--name-only` QUOTES a path carrying non-ASCII or special
  # characters, and a quoted name would silently fail to match. Materialized to
  # a temp file rather than read through `< <(…)`: a live process substitution
  # in shell this project ships is refused by the preflight census.
  DGI_NAMES="$(mktemp)"
  git diff --staged --name-only -z > "$DGI_NAMES" 2>/dev/null
  dgi_hit=""
  while IFS= read -r -d '' p; do
    case "$p" in
      *.md|scripts/generate-doc-index.py) dgi_hit="$p"; break ;;
    esac
  done < "$DGI_NAMES"
  rm -f "$DGI_NAMES"

  if [ -n "$dgi_hit" ]; then
    # MID-MERGE ROUTING, decided ONCE and used by every arm below: mid-merge
    # this leg reports but never refuses (reason 1). Asked of git.
    dgi_merge="$(git -C "$TOPLEVEL" rev-parse -q --verify MERGE_HEAD 2>/dev/null)"
    dgi_soft=0
    [ -n "$dgi_merge" ] && dgi_soft=1

    # The archive pathspec comes from `ls-files --cached`, so `git archive` is
    # never handed a path the staged tree lacks (which is fatal, and would read
    # as a checker failure). The generator rides along so a commit that CHANGES
    # it is graded by the version it introduces, not by the one on disk — the
    # same reasoning as the post-clear manifest leg's PCM_RUN.
    DGI_PATHFILE="$(mktemp)"
    git ls-files --cached -z -- '*.md' 'scripts/generate-doc-index.py' > "$DGI_PATHFILE" 2>/dev/null
    dgi_paths=()
    while IFS= read -r -d '' p; do [ -n "$p" ] && dgi_paths+=("$p"); done < "$DGI_PATHFILE"
    rm -f "$DGI_PATHFILE"
    # SHAPE GUARD on the comparator's own input: an empty or implausibly short
    # path set would make the check grade an empty estate and PASS while looking
    # healthy. The set always holds at least the guide and the generator.
    if [ "${#dgi_paths[@]}" -lt 2 ]; then
      echo "pre-commit: doc-index check — the staged tree holds ${#dgi_paths[@]} of its input path(s); expected at least 2 (the guide and the generator). Not grading an empty estate." >&2
      if [ "$dgi_soft" -eq 1 ]; then
        echo "  Reporting only: a merge is in progress (MERGE_HEAD ${dgi_merge:0:8})." >&2
      else
        echo "pre-commit: BLOCKED — refusing rather than passing on an estate this leg could not read." >&2
        exit 1
      fi
    else
      DGI_TMP="$(mktemp -d)"
      git archive --format=tar "$(git write-tree)" -- "${dgi_paths[@]}" \
        | tar -x -C "$DGI_TMP"
      dgi_arc=$?
      if [ "$dgi_arc" -ne 0 ]; then
        echo "pre-commit: doc-index check — could not materialize the staged tree (archive/extract rc=$dgi_arc)." >&2
        rm -rf "$DGI_TMP"
        if [ "$dgi_soft" -eq 1 ]; then
          echo "  Reporting only: a merge is in progress (MERGE_HEAD ${dgi_merge:0:8})." >&2
        else
          echo "pre-commit: BLOCKED — the check could not run and its verdict is unknown." >&2
          exit 1
        fi
      else
        # THE MATERIALIZED TREE IS MADE A REAL CHECKOUT. The generator discovers
        # its input set with `git ls-files`, which reads an INDEX — so the
        # extract gets one of its own. This is the whole of reason 2's fix.
        #
        # A HOOK IS A GIT SUBPROCESS, AND ITS ENVIRONMENT IS GIT'S. `git commit`
        # exports the repo binding to every hook it runs, and in a LINKED
        # WORKTREE those values are ABSOLUTE — MEASURED 2026-09-06, a pre-commit
        # hook in a worktree sees `GIT_DIR=<repo>/.git/worktrees/<name>` and
        # `GIT_INDEX_FILE=<repo>/.git/worktrees/<name>/index`, where a plain
        # checkout sees no GIT_DIR at all and a relative `.git/index`. `-C
        # <dir>` does NOT override that: GIT_DIR with no work-tree setting means
        # "cwd IS the work tree", so `git -C "$DGI_TMP" init` creates NO
        # repository and `git -C "$DGI_TMP" add -A` writes THE REAL INDEX —
        # staging the deletion of every tracked path absent from the extract.
        # WITNESSED, not reasoned: in a worktree fixture the real index went
        # from `.githooks/pre-commit a.txt` to `only.md` across those two
        # commands, and `git commit` re-reads the index after the hook.
        #
        # So every call aimed at the extract runs with that binding CLEARED,
        # including the checker — the generator makes its own `git -C <root>`
        # calls, and an inherited GIT_DIR would give it the real repo's file
        # list and the real repo's MERGE_HEAD. The variable list is DERIVED from
        # git itself (`rev-parse --local-env-vars`), never copied here: a list
        # that git extends and this file does not is a fork that fails silently.
        # The repo-side calls above (`diff --staged`, `ls-files --cached`,
        # `write-tree`, `archive`, the MERGE_HEAD probe) deliberately KEEP the
        # binding — they are meant to read the real repo.
        DGI_ENVFILE="$(mktemp)"
        git rev-parse --local-env-vars > "$DGI_ENVFILE" 2>/dev/null
        dgi_unset=()
        while IFS= read -r v; do [ -n "$v" ] && dgi_unset+=(-u "$v"); done < "$DGI_ENVFILE"
        rm -f "$DGI_ENVFILE"
        # SHAPE GUARD on that list: an empty one would silently restore the
        # broken behaviour, and `env` with no -u is a no-op that looks fine.
        if [ "${#dgi_unset[@]}" -eq 0 ]; then
          rm -rf "$DGI_TMP"
          echo "pre-commit: doc-index check — \`git rev-parse --local-env-vars\` returned nothing, so the repo binding cannot be cleared and git must not be run against the staged extract." >&2
          if [ "$dgi_soft" -eq 1 ]; then
            echo "  Reporting only: a merge is in progress (MERGE_HEAD ${dgi_merge:0:8})." >&2
          else
            echo "pre-commit: BLOCKED — the check could not run safely and its verdict is unknown." >&2
            exit 1
          fi
        else
        env "${dgi_unset[@]}" git -C "$DGI_TMP" init -q >/dev/null 2>&1
        env "${dgi_unset[@]}" git -C "$DGI_TMP" add -A >/dev/null 2>&1
        DGI_RUN="$DGI_TMP/scripts/generate-doc-index.py"
        [ -f "$DGI_RUN" ] || DGI_RUN="$DGI_GEN"
        # rc on its own line, never `if ! cmd` — that records 0 for every
        # failure, and never through a pipeline either: inside `$(cmd | sed)`
        # the pipeline runs in a SUBSHELL, so the parent's PIPESTATUS is not
        # the checker's. Captured to a file, rc taken bare, then the temp-dir
        # prefix rewritten out — the author reads these messages after the
        # directory is gone.
        DGI_OUT="$(mktemp)"
        env "${dgi_unset[@]}" python3 "$DGI_RUN" --check --root "$DGI_TMP" > "$DGI_OUT" 2>&1
        dgi_rc=$?
        sed -i "s#$DGI_TMP/##g" "$DGI_OUT"
        dgi_out="$(cat "$DGI_OUT")"
        rm -f "$DGI_OUT"
        rm -rf "$DGI_TMP"
        if [ "$dgi_rc" -ne 0 ] && [ "$dgi_soft" -eq 1 ]; then
          {
            echo
            echo "pre-commit: WARNING — docs/document-guide.md is RED on the STAGED content, and this commit is NOT being refused because a merge is IN PROGRESS (MERGE_HEAD ${dgi_merge:0:8})."
            printf '%s\n' "$dgi_out"
            cat <<'MSG'

  The remedy is unavailable until the merge commit exists:
  scripts/generate-doc-index.py --write REFUSES while MERGE_HEAD is set,
  deliberately and with no escape hatch. Blocking here would leave no way
  forward but `git commit --no-verify`, so this reports instead.

  AFTER this merge lands, regenerate and commit the guide:

    run it:  python3 scripts/generate-doc-index.py --write
    or:      make doc-index

MSG
          } >&2
        elif [ "$dgi_rc" -ne 0 ]; then
          printf '%s\n' "$dgi_out" >&2
          cat >&2 <<'MSG'

pre-commit: BLOCKED — docs/document-guide.md is RED on the STAGED content (the
  generated document map has drifted from the frontmatter it is generated from).

  The guide is GENERATED, not written, and `make lint` runs this same check —
  so a commit that lands a stale guide turns lint RED for everyone, and the
  drift is found by a merge window instead of by its author.

    run it:  python3 scripts/generate-doc-index.py --write
    or:      make doc-index
    then re-stage docs/document-guide.md and commit again.

MSG
          exit 1
        fi
        fi
      fi
    fi
  fi
fi
# ---------------------------------------------------------------------------
# FILE-ROLES REGISTRY: REFUSE A HAND EDIT BY ROLE (2026-09-18, lane
# pre-commit-refuses-hand-edits-by-role, L7 of
# docs/2026-09-17-file-roles-registry-design.md section 14).
#
# WHY HERE. `docs/document-guide.md` and `docs/POST-CLEAR.md` already have
# their OWN dedicated legs above (doc-index drift, post-clear manifest drift).
# `docs/code-map.md` — role `generated` in docs/file-roles.tsv, same shape as
# the doc-index guide — has NO commit-time protection at all today; a hand
# edit to it is only caught by `make lint`, a merge window later. Rather than
# writing a third one-off leg per generated file (and a fourth, a fifth, ...
# every time the registry grows a row), this leg consults the registry ONCE
# and acts generically on any row whose role is `generated`, `generated-fields`
# or `frozen-after-cut` — including the two files already covered above (this
# leg is redundant there, not a conflict) and every future row with no new
# leg required.
#
# THE CHECK ITSELF lives in scripts/file-roles-precommit-check.py, not here:
# it needs `file_roles.load_registry` / `file_roles.resolve` (the ONE shared
# predicate, CLAUDE.md) and a staged-tree materialization generic enough for
# an ARBITRARY producer, which is Python-shaped, not bash-shaped. Read that
# script's own module docstring for the full contract (staged-tree grading,
# the two escape hatches, the registry's own fail-closed path).
#
# MID-MERGE: THIS LEG DOES NOT WARN-INSTEAD-OF-BLOCK, unlike the doc-index and
# post-clear-manifest legs above. Those soften because their only remedy
# (`--write`) itself REFUSES while MERGE_HEAD is set, so blocking would trap
# an author with no way out but `--no-verify`. This leg's escape hatch,
# FILE_ROLES_OVERRIDE, is a plain environment variable with no git operation
# behind it — it works identically whether or not a merge is in progress — so
# the trap that motivates the softer path elsewhere does not exist here, and
# blocking mid-merge is the ordinary, correct behaviour.
#
# REPO-COUPLING GUARD: same convention as every other leg here — skip loudly
# when the committing repo does not carry the checker, checked structurally.
FRP_CHECK="$TOPLEVEL/scripts/file-roles-precommit-check.py"
FRP_LIB="$TOPLEVEL/scripts/file_roles.py"
if [ ! -f "$FRP_CHECK" ] || [ ! -f "$FRP_LIB" ]; then
  echo "pre-commit: SKIP — file-roles precommit check (scripts/file-roles-precommit-check.py or scripts/file_roles.py not found in $TOPLEVEL; not this {{PROJECT_SLUG}} checkout)" >&2
elif ! command -v python3 >/dev/null 2>&1; then
  # FAIL CLOSED, same principle as every other leg here.
  echo "pre-commit: python3 NOT FOUND — refusing the commit (file-roles precommit check cannot run)." >&2
  exit 1
else
  FRP_OUT="$(mktemp)"
  python3 "$FRP_CHECK" --repo "$TOPLEVEL" > "$FRP_OUT" 2>&1
  frp_rc=$?
  # rc on its own line, never `if ! cmd` — that records 0 for every failure.
  if [ "$frp_rc" -ne 0 ]; then
    cat "$FRP_OUT" >&2
    echo >&2
    cat >&2 <<'MSG'
pre-commit: BLOCKED — a staged file's registry role (docs/file-roles.tsv)
  licenses no hand edit to it (see the FILE-ROLES-PRECOMMIT line(s) above).

  Escape hatches: run the named producer and stage its output, or set
  FILE_ROLES_OVERRIDE="<reason>" for this commit (both are logged, never
  silent). Background: docs/2026-09-17-file-roles-registry-design.md.
MSG
    rm -f "$FRP_OUT"
    exit 1
  fi
  # An override or an ambiguous-role note prints even on the ALLOWED path —
  # a swallowed exception is not an option (CLAUDE.md), and an override that
  # nobody sees is not logged, it is silent with extra steps.
  cat "$FRP_OUT" >&2
  rm -f "$FRP_OUT"
fi

# ---------------------------------------------------------------------------
# CONFLICT-MARKER LINT GUARD (2026-08-25) — refuses a commit that would
# introduce live git conflict markers into ANY tracked file.
#
# WHY THIS EXISTS. `docs/reserved-identifiers.md` carried unresolved
# `<<<<<<< HEAD` / `=======` / `>>>>>>> 8d1c2814` markers from merge
# 82d530281 (2026-08-25 07:04) until fixed by hand in f2d42fd1b (08:00).
# `make fmt-check`, `make clippy`, `make lint` and every selftest ran GREEN
# the whole time — nothing checked for this class at all.
#
# Every commit, unconditionally — a marker can land in any file, unlike the
# scoped gates above. Checks the STAGED tree (`git write-tree` + `git
# archive`, same mechanism as the decision-citation arm), never the working
# tree: an unstaged fix must not make a staged violation look clean.
#
# Anchoring, the setext/divider false-positive avoidance, and the structural
# `CONFLICT-MARKER-LINT: allow <reason>` exemption are documented in
# `scripts/conflict-marker-check.py`'s own docstring — no dedicated bypass
# beyond `git commit --no-verify` (the documented escape used everywhere
# else in this hook); `make lint`'s `conflict-markers` leg is the backstop
# for anything that reaches the tree another way.
#
# DECISION (repo-coupling guard, 2026-08-26): unlike the decision-citation /
# registry-drift / §12 legs above, this check is REPO-AGNOSTIC in what it
# means — live git conflict markers are a defect in ANY repo, not just
# {{PROJECT_SLUG}}'s own governance files — so it is worth still running on a foreign
# repo that points core.hooksPath here, same posture as the gitleaks arm.
# What is NOT repo-agnostic is where the CHECKER PROGRAM lives: only {{PROJECT_SLUG}}'s
# own checkout carries `scripts/conflict-marker-check.py`. So CM_SCRIPT below
# tries TOPLEVEL first (the common case: this repo IS {{PROJECT_SLUG}}) and falls back
# to HOOK_HOME (a real foreign repo that points core.hooksPath at {{PROJECT_SLUG}}'s
# ABSOLUTE .githooks still has the checker reachable one level up from the
# running hook file). Only when NEITHER has it — install-hooks.sh's own
# self-test, which copies just the pre-commit file into an isolated
# throwaway clone without scripts/ — does this SKIP rather than misfire.
if ! command -v python3 >/dev/null 2>&1; then
  # FAIL CLOSED, same principle as the gitleaks and decision-citation arms.
  echo "pre-commit: python3 NOT FOUND — refusing the commit (conflict-marker gate cannot run)." >&2
  exit 1
fi
CM_SCRIPT=""
if [ -f "$TOPLEVEL/scripts/conflict-marker-check.py" ]; then
  CM_SCRIPT="$TOPLEVEL/scripts/conflict-marker-check.py"
elif [ -n "$HOOK_HOME" ] && [ -f "$HOOK_HOME/scripts/conflict-marker-check.py" ]; then
  CM_SCRIPT="$HOOK_HOME/scripts/conflict-marker-check.py"
fi
if [ -z "$CM_SCRIPT" ]; then
  echo "pre-commit: SKIP — conflict-marker gate (scripts/conflict-marker-check.py not found in $TOPLEVEL or $HOOK_HOME)" >&2
  exit 0
fi
CM_TMP="$(mktemp -d)"
cm_paths=()
while IFS= read -r -d '' p; do [ -n "$p" ] && cm_paths+=("$p"); done < <(
  git ls-files --cached -z 2>/dev/null)
if [ "${#cm_paths[@]}" -gt 0 ]; then
  # `set -o pipefail` (top of file) makes $? the pipeline's rightmost
  # non-zero status, but nothing reads it unless captured on the SAME line —
  # captured explicitly rather than relying on it silently propagating,
  # because a failed or PARTIAL extract (ENOSPC on the temp filesystem is a
  # lived failure mode here) must never be read as "nothing to scan, pass".
  git archive --format=tar "$(git write-tree)" -- "${cm_paths[@]}" \
    | tar -x -C "$CM_TMP"
  ar_rc=$?
  if [ "$ar_rc" -ne 0 ]; then
    echo "pre-commit: BLOCKED — could not materialize the staged tree for the conflict-marker gate (archive/extract rc=$ar_rc)." >&2
    rm -rf "$CM_TMP"
    exit 1
  fi
  cm_rc=0
  python3 "$CM_SCRIPT" --root "$CM_TMP" --paths "${cm_paths[@]}" || cm_rc=$?
  # rc=2 from the checker (usage/IO, INCLUDING its own vacuous-scan refusal —
  # a non-empty path list that read zero files) blocks exactly like rc=1: an
  # unreadable staged tree is not evidence of a clean one.
  if [ "$cm_rc" -ne 0 ]; then
    echo >&2
    echo "pre-commit: BLOCKED — conflict-marker gate is RED on the STAGED content (rc=$cm_rc)." >&2
    echo "  (see the finding lines above; docs/reserved-identifiers.md's own history is the model of what this catches)" >&2
    rm -rf "$CM_TMP"
    exit 1
  fi
fi
rm -rf "$CM_TMP"

exit 0
